├── .browserslistrc ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yml │ └── deploy.yml ├── .gitignore ├── .npmrc ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── app.vue ├── assets ├── css │ └── global.scss ├── theme-dark.json └── theme-light.json ├── components ├── ActionTrigger.vue ├── Alert.vue ├── Button.vue ├── ColorModeSwitch.vue ├── ContextMenu.vue ├── DiffEditor.client.vue ├── Icon.vue ├── IconButton.vue ├── SquareLoader.vue └── ToggleButton.vue ├── composables ├── use-base-url.ts ├── use-diffr-head.ts ├── use-font-ready.ts ├── use-theme-toggle.ts └── use-url-state.ts ├── encoding-worker ├── encoding-tools.ts ├── encoding-utils.ts └── encoding-worker.ts ├── logo.png ├── modules ├── fix-manifest │ └── module.ts └── floating-vue │ ├── module.ts │ └── plugin.ts ├── nuxt.config.ts ├── package-lock.json ├── package.json ├── pages ├── about.vue └── index.vue ├── public ├── apple-touch-icon.png ├── diffr-pwa-192x192.png ├── diffr-pwa-512x512-maskable.png ├── diffr-pwa-512x512.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.svg ├── icons │ ├── blank.svg │ ├── copy.svg │ ├── cut.svg │ ├── diffr.svg │ ├── indent.svg │ ├── moon.svg │ ├── paste.svg │ ├── screen.svg │ ├── share.svg │ ├── split.svg │ ├── sun.svg │ ├── swap.svg │ └── trash.svg ├── logo.svg └── maskable.svg ├── stores ├── color-preference-cycle.ts └── encoding-worker.ts ├── tailwind.config.ts ├── tsconfig.json └── util └── encoding-worker ├── encoding-tools.ts ├── encoding-utils.ts └── encoding-worker.ts /.browserslistrc: -------------------------------------------------------------------------------- 1 | last 2 chrome versions 2 | last 2 firefox versions 3 | ios_saf >= 15.4 4 | safari >= 15.4 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | time: '04:00' 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: sass-loader 11 | versions: 12 | - '>= 11.a, < 12' 13 | - dependency-name: '*' 14 | update-types: 15 | - 'version-update:semver-patch' 16 | - 'version-update:semver-minor' 17 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1 16 | with: 17 | github-token: '${{ secrets.GITHUB_TOKEN }}' 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: ${{ contains(steps.metadata.outputs.dependency-names, 'sass') && (steps.metadata.outputs.update-type == 'version-update:semver-patch' || steps.metadata.outputs.update-type == 'version-update:semver-minor') }} 20 | run: gh pr merge --auto --squash "$PR_URL" 21 | env: 22 | PR_URL: ${{ github.event.pull_request.html_url }} 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deployment CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | persist-credentials: false 16 | - uses: actions/setup-node@v3 17 | with: 18 | node-version: '18' 19 | - run: npm ci 20 | - run: npm run generate 21 | env: 22 | BASE_URL: /diffr/ 23 | - uses: JamesIves/github-pages-deploy-action@v4 24 | with: 25 | token: ${{ secrets.ACCESS_TOKEN }} 26 | branch: gh-pages 27 | folder: dist 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | legacy-peer-deps=true 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | htmlWhitespaceSensitivity: css 4 | insertPragma: false 5 | jsxBracketSameLine: false 6 | jsxSingleQuote: false 7 | printWidth: 80 8 | proseWrap: preserve 9 | requirePragma: false 10 | semi: false 11 | singleQuote: true 12 | tabWidth: 2 13 | trailingComma: all 14 | useTabs: false 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "titleBar.activeBackground": "#47b5e8", 4 | "titleBar.activeForeground": "#15202b", 5 | "titleBar.inactiveBackground": "#47b5e899", 6 | "titleBar.inactiveForeground": "#15202b99", 7 | "sash.hoverBorder": "#74c7ee", 8 | "commandCenter.border": "#15202b99" 9 | }, 10 | "peacock.color": "#47b5e8" 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | The Diffr logo: two slightly overlapping diamond shapes 5 | 6 |
7 |
8 | 9 | # Diffr 10 | 11 | > Create and share diffs with Diffr, the privacy focused online text diffing tool. 12 | 13 | This is the source code repository of the Diffr app. You can find the app at [loilo.github.io/diffr](https://loilo.github.io/diffr/). 14 | 15 | ## Technologies 16 | 17 | The core technologies this project uses are: 18 | 19 | 20 | Technology | Purpose 21 | -|- 22 | **[Monaco Editor](https://microsoft.github.io/monaco-editor/)** | A text/code editor by Microsoft, used for the editing and diffing area itself. 23 | **[Nuxt](https://nuxt.com/)** | An application framework for Vue.js, used for prerendering, PWA support and overall application structure. 24 | **[`lz-string`](https://www.npmjs.com/package/lz-string)** | A quick and space-efficient compression algorithm, used for serializing the current app state in the URL anchor in as few characters as possible. 25 | **[GitHub Pages](https://pages.github.com/)** | This app does (purposefully) not generate any income. Therefore, free hosting is essential to keep it running. 26 | 27 | ## Setup 28 | 29 | Clone this repository and install its dependencies using [npm](https://npmjs.com/). 30 | 31 | ```bash 32 | npm ci 33 | ``` 34 | 35 | ## Local Development 36 | 37 | Start a local dev server with hot reloading: 38 | 39 | ```bash 40 | npm run dev 41 | ``` 42 | 43 | ## Generate Production Site 44 | 45 | Create a production-ready site in the `dist` folder: 46 | 47 | ``` 48 | npm run generate 49 | ``` 50 | 51 | Set the `BASE_URL` environment variable to create a build that can be hosted in a subfolder of a domain, e.g.: 52 | 53 | ``` 54 | BASE_URL=/diffr/ npm run generate 55 | ``` -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /assets/css/global.scss: -------------------------------------------------------------------------------- 1 | body { 2 | @apply bg-slate-50 text-slate-600 dark:bg-slate-900 dark:text-slate-500; 3 | } 4 | 5 | div:where(.diffr-prose) { 6 | @apply prose prose-slate dark:prose-invert prose-headings:font-normal prose-headings:text-slate-400 prose-a:rounded-md prose-a:text-blue-400 prose-a:no-underline focus-visible:prose-a:outline focus-visible:prose-a:outline-2 focus-visible:prose-a:outline-offset-8 focus-visible:prose-a:outline-current dark:prose-headings:text-slate-500 dark:prose-a:text-teal-400; 7 | } 8 | -------------------------------------------------------------------------------- /assets/theme-dark.json: -------------------------------------------------------------------------------- 1 | { 2 | "base": "vs-dark", 3 | "inherit": false, 4 | "colors": { 5 | "diffEditor.insertedTextBackground": "#5af78e44", 6 | "diffEditor.insertedTextForeground": "#95e6cb99", 7 | "diffEditor.removedTextBackground": "#ff5c5744", 8 | "diffEditor.removedTextForeground": "#f07178bb", 9 | "editor.contextMenuBackground": "#2c3340", 10 | "editor.background": "#19212F", 11 | "editor.findMatchBackground": "#ffcc6633", 12 | "editor.findMatchHighlightBackground": "#ffcc6633", 13 | "editor.foreground": "#d9d7ce", 14 | "editor.hoverHighlightBackground": "#2fb1f088", 15 | "editor.selectionBackground": "#47b5e866", 16 | "editorLineNumber.foreground": "#3d4752", 17 | "editorLink.activeForeground": "#2fb1f0", 18 | "editorWhitespace.foreground": "#3d475288", 19 | "focusBorder": "#2fb1f088", 20 | "foreground": "#d9d7ce", 21 | "input.border": "#7386994c", 22 | "textLink.foreground": "#2fb1f0" 23 | }, 24 | "rules": [ 25 | { 26 | "token": "", 27 | "foreground": "f3f2ef" 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /assets/theme-light.json: -------------------------------------------------------------------------------- 1 | { 2 | "base": "vs", 3 | "inherit": false, 4 | "colors": { 5 | "focusBorder": "#47b5e8", 6 | "foreground": "#686968", 7 | "editorGroup.emptyBackground": "#F3F4F5", 8 | "editor.selectionBackground": "#47b5e844", 9 | "editor.background": "#FAFBFC", 10 | "editor.foreground": "#565869", 11 | "editorWhitespace.foreground": "#ADB1C255", 12 | "editor.contextMenuBackground": "#ffffff", 13 | "editor.findMatchBackground": "#00E6E06A", 14 | "editor.findMatchHighlightBackground": "#00E6E02A", 15 | "editor.hoverHighlightBackground": "#47b5e844", 16 | "input.border": "#E9EAEB", 17 | "editorLink.activeForeground": "#47b5e8", 18 | "textLink.foreground": "#47b5e8", 19 | "diffEditor.insertedTextBackground": "#2DAE5844", 20 | "diffEditor.removedTextBackground": "#FFAEAC5b", 21 | "editorLineNumber.foreground": "#9194A2" 22 | }, 23 | "rules": [ 24 | { 25 | "token": "", 26 | "foreground": "565869" 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /components/ActionTrigger.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 15 | 16 | 79 | 80 | 95 | -------------------------------------------------------------------------------- /components/Alert.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /components/Button.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | -------------------------------------------------------------------------------- /components/ColorModeSwitch.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 61 | 62 | 166 | -------------------------------------------------------------------------------- /components/ContextMenu.vue: -------------------------------------------------------------------------------- 1 | 106 | 107 | 170 | 171 | 181 | -------------------------------------------------------------------------------- /components/DiffEditor.client.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 322 | 323 | 337 | -------------------------------------------------------------------------------- /components/Icon.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | 17 | 29 | -------------------------------------------------------------------------------- /components/IconButton.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 49 | -------------------------------------------------------------------------------- /components/SquareLoader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 79 | 80 | 108 | 109 | 194 | -------------------------------------------------------------------------------- /components/ToggleButton.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | -------------------------------------------------------------------------------- /composables/use-base-url.ts: -------------------------------------------------------------------------------- 1 | export function useBaseUrl() { 2 | let config = useRuntimeConfig() 3 | let baseUrl = config.public.baseURL ?? '/' 4 | if (!baseUrl.endsWith('/')) { 5 | baseUrl += '/' 6 | } 7 | return baseUrl 8 | } 9 | 10 | export function useAbsoluteUrl(file: string) { 11 | return useBaseUrl() + file.replace(/^\/*/, '') 12 | } 13 | -------------------------------------------------------------------------------- /composables/use-diffr-head.ts: -------------------------------------------------------------------------------- 1 | export function useDiffrHead() { 2 | useHead({ 3 | title: 'Diffr', 4 | link: [ 5 | { 6 | rel: 'icon', 7 | href: useAbsoluteUrl('favicon.ico'), 8 | }, 9 | { 10 | rel: 'icon', 11 | type: 'image/svg+xml', 12 | href: useAbsoluteUrl('favicon.svg'), 13 | }, 14 | { 15 | rel: 'apple-touch-icon', 16 | sizes: '180x180', 17 | href: useAbsoluteUrl('apple-touch-icon.png'), 18 | }, 19 | { 20 | rel: 'mask-icon', 21 | color: '#FAFBFC', 22 | href: useAbsoluteUrl('maskable.svg'), 23 | }, 24 | ], 25 | script: [ 26 | { 27 | innerHTML: `if ( 28 | localStorage.theme === 'dark' || 29 | ( 30 | ( 31 | !('theme' in localStorage) || 32 | localStorage.theme === 'auto' 33 | ) && 34 | window.matchMedia('(prefers-color-scheme: dark)').matches 35 | ) 36 | ) { 37 | document.documentElement.classList.add('dark') 38 | } else { 39 | document.documentElement.classList.remove('dark') 40 | }`, 41 | }, 42 | ], 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /composables/use-font-ready.ts: -------------------------------------------------------------------------------- 1 | export function useFontReady(font: string, size = 12) { 2 | if (typeof document !== 'object') return readonly(ref(false)) 3 | 4 | let checkString = `${size}px ${font}` 5 | 6 | let ready = ref(document.fonts.check(checkString)) 7 | 8 | useEventListener(document.fonts, 'loadingdone', () => { 9 | ready.value = document.fonts.check(checkString) 10 | }) 11 | 12 | return readonly(ready) 13 | } 14 | -------------------------------------------------------------------------------- /composables/use-theme-toggle.ts: -------------------------------------------------------------------------------- 1 | export function useThemeToggle() { 2 | let colorMode = useColorMode() 3 | let cycle = useColorPreferenceCycleStore() 4 | 5 | function toggle() { 6 | cycle.next() 7 | colorMode.preference = cycle.state 8 | } 9 | 10 | let theme = computed(() => { 11 | if (colorMode.preference === 'system') { 12 | return colorMode.value === 'dark' ? 'dark' : 'light' 13 | } else { 14 | return colorMode.preference === 'dark' ? 'dark' : 'light' 15 | } 16 | }) 17 | 18 | return reactive({ 19 | auto: computed(() => colorMode.preference === 'system'), 20 | theme, 21 | toggle, 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /composables/use-url-state.ts: -------------------------------------------------------------------------------- 1 | // Handle LZ encoding/decoding from/to the URL hash in a worker 2 | 3 | import type { 4 | WorkerMessage, 5 | EncodingWorkerResponse, 6 | DecodingWorkerResponse, 7 | } from '~/util/encoding-worker/encoding-utils' 8 | 9 | export function useUrlState() { 10 | if (import.meta.env.SSR) 11 | return { 12 | async read() { 13 | return null 14 | }, 15 | async write() {}, 16 | async encode(data: T) {}, 17 | async decode(serialized: string) {}, 18 | } 19 | 20 | let worker = useEncodingWorkerStore().get() 21 | 22 | function decode(serialized: string) { 23 | return new Promise(resolve => { 24 | let id = crypto.randomUUID() 25 | 26 | let stop = useEventListener( 27 | worker, 28 | 'message', 29 | (event: MessageEvent) => { 30 | if (event.data?.id !== id) return 31 | 32 | stop() 33 | resolve(event.data.payload) 34 | }, 35 | ) 36 | 37 | worker.postMessage({ 38 | type: 'decode', 39 | id, 40 | payload: serialized, 41 | } satisfies WorkerMessage) 42 | }) 43 | } 44 | 45 | async function read() { 46 | const hash = document.location.hash.slice(1) 47 | if (!hash) { 48 | return null 49 | } 50 | 51 | return decode(hash) 52 | } 53 | 54 | function encode(data: T) { 55 | return new Promise(resolve => { 56 | let id = crypto.randomUUID() 57 | 58 | let stop = useEventListener( 59 | worker, 60 | 'message', 61 | (event: MessageEvent) => { 62 | if (event.data?.id !== id) return 63 | 64 | stop() 65 | resolve(event.data.payload) 66 | }, 67 | ) 68 | 69 | worker.postMessage({ 70 | type: 'encode', 71 | id, 72 | payload: data, 73 | } satisfies WorkerMessage) 74 | }) 75 | } 76 | 77 | async function write(data: T) { 78 | let hash = await encode(data) 79 | 80 | const url = new URL(window.location.href) 81 | url.hash = hash 82 | history.replaceState(history.state, '', url.href) 83 | } 84 | 85 | return { read, write, encode, decode } 86 | } 87 | -------------------------------------------------------------------------------- /encoding-worker/encoding-tools.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decompressFromEncodedURIComponent, 3 | compressToEncodedURIComponent, 4 | } from 'lz-string' 5 | 6 | export function encode(data: any) { 7 | return compressToEncodedURIComponent(JSON.stringify(data)) 8 | } 9 | 10 | export function decode(serialized: any) { 11 | return JSON.parse(decompressFromEncodedURIComponent(serialized)) 12 | } 13 | -------------------------------------------------------------------------------- /encoding-worker/encoding-utils.ts: -------------------------------------------------------------------------------- 1 | export type WorkerMessage = 2 | | { 3 | type: 'encode' 4 | id: string 5 | payload: any 6 | } 7 | | { 8 | type: 'decode' 9 | id: string 10 | payload: string 11 | } 12 | 13 | export type EncodingWorkerResponse = { 14 | type: 'encoded' 15 | payload: string 16 | id: string 17 | } 18 | 19 | export type DecodingWorkerResponse = { 20 | type: 'decoded' 21 | payload: T 22 | id: string 23 | } 24 | 25 | export function ensureRecord( 26 | message: unknown, 27 | ): asserts message is Record { 28 | if (typeof message !== 'object' || message === null) { 29 | throw new Error('Invalid message') 30 | } 31 | } 32 | 33 | export function validateMessage( 34 | message: unknown, 35 | ): asserts message is WorkerMessage { 36 | ensureRecord(message) 37 | 38 | if (typeof message.id !== 'string') { 39 | throw new Error('Invalid message id') 40 | } 41 | 42 | switch (message.type) { 43 | case 'encode': 44 | if (typeof message.payload !== 'object') { 45 | throw new Error('Invalid message payload') 46 | } 47 | break 48 | 49 | case 'decode': 50 | if (typeof message.payload !== 'string') { 51 | throw new Error('Invalid message payload') 52 | } 53 | break 54 | 55 | default: 56 | throw new Error('Invalid message type') 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /encoding-worker/encoding-worker.ts: -------------------------------------------------------------------------------- 1 | // Handle the actual LZ encoding/decoding 2 | import { encode, decode } from './encoding-tools' 3 | import { 4 | DecodingWorkerResponse, 5 | EncodingWorkerResponse, 6 | validateMessage, 7 | } from './encoding-utils' 8 | 9 | addEventListener('message', event => { 10 | let data = event.data 11 | validateMessage(data) 12 | 13 | switch (data.type) { 14 | case 'encode': 15 | postMessage({ 16 | type: 'encoded', 17 | payload: encode(data.payload), 18 | id: data.id, 19 | } satisfies EncodingWorkerResponse) 20 | break 21 | 22 | case 'decode': 23 | postMessage({ 24 | type: 'decoded', 25 | payload: decode(data.payload), 26 | id: data.id, 27 | } satisfies DecodingWorkerResponse) 28 | break 29 | } 30 | }) 31 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/logo.png -------------------------------------------------------------------------------- /modules/fix-manifest/module.ts: -------------------------------------------------------------------------------- 1 | // The manifest.webmanifest file is placed in the wrong folder by VitePWA, 2 | // so we need to fix this after building. 3 | 4 | import { defineNuxtModule } from '@nuxt/kit' 5 | 6 | import fs from 'node:fs' 7 | import * as path from 'node:path' 8 | 9 | import consola from 'consola' 10 | 11 | export default defineNuxtModule({ 12 | async setup(_options, nuxt) { 13 | if ((nuxt.options.app.baseURL ?? '/') === '/') return 14 | 15 | nuxt.hook('close', async () => { 16 | let outDir = path.join(nuxt.options.rootDir, '.output', 'public') 17 | 18 | let wrongManifestPath = path.join( 19 | outDir, 20 | nuxt.options.app.baseURL, 21 | 'manifest.webmanifest', 22 | ) 23 | let correctManifestPath = path.join(outDir, 'manifest.webmanifest') 24 | 25 | if (fs.existsSync(wrongManifestPath)) { 26 | consola.info('Moving manifest.webmanifest') 27 | fs.renameSync(wrongManifestPath, correctManifestPath) 28 | } else { 29 | consola.success('No inappropiate manifest.webmanifest found') 30 | } 31 | }) 32 | }, 33 | }) 34 | -------------------------------------------------------------------------------- /modules/floating-vue/module.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtModule, addPlugin, createResolver } from '@nuxt/kit' 2 | 3 | export default defineNuxtModule({ 4 | setup(options, nuxt) { 5 | nuxt.options.vue.compilerOptions.directiveTransforms = 6 | nuxt.options.vue.compilerOptions.directiveTransforms || {} 7 | nuxt.options.vue.compilerOptions.directiveTransforms.tooltip = () => ({ 8 | props: [], 9 | needRuntime: true, 10 | }) 11 | 12 | nuxt.options.css.push('floating-vue/dist/style.css') 13 | 14 | const { resolve } = createResolver(import.meta.url) 15 | 16 | addPlugin({ 17 | ssr: false, 18 | mode: 'client', 19 | src: resolve('./plugin'), 20 | }) 21 | 22 | // @TODO remove when floating-ui supports native ESM 23 | nuxt.options.build.transpile.push( 24 | 'floating-vue', 25 | '@floating-ui/core', 26 | '@floating-ui/dom', 27 | ) 28 | }, 29 | }) 30 | -------------------------------------------------------------------------------- /modules/floating-vue/plugin.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtPlugin } from '#app' 2 | import FloatingVue from 'floating-vue' 3 | 4 | export default defineNuxtPlugin(nuxtApp => { 5 | // @TODO cutomization 6 | nuxtApp.vueApp.use(FloatingVue) 7 | }) 8 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | const baseURL = (String(import.meta.env.BASE_URL ?? '') + '/').replace( 3 | /\/+$/, 4 | '/', 5 | ) 6 | 7 | export default defineNuxtConfig({ 8 | modules: [ 9 | '@vite-pwa/nuxt', 10 | '@nuxtjs/color-mode', 11 | '@nuxtjs/tailwindcss', 12 | '@pinia/nuxt', 13 | '@vueuse/nuxt', 14 | '@nuxtjs/google-fonts', 15 | 'nuxt-headlessui', 16 | 'nuxt-monaco-editor', 17 | '~/modules/floating-vue/module', 18 | '~/modules/fix-manifest/module', 19 | ], 20 | app: { 21 | baseURL, 22 | keepalive: true, 23 | }, 24 | runtimeConfig: { 25 | public: { 26 | baseURL, 27 | }, 28 | }, 29 | css: ['~/assets/css/global.scss'], 30 | pwa: { 31 | registerType: 'autoUpdate', 32 | base: baseURL, 33 | buildBase: baseURL, 34 | manifest: { 35 | name: 'Diffr | The Online Text Diffing Tool', 36 | short_name: 'Diffr', 37 | theme_color: '#19212F', 38 | icons: [ 39 | { 40 | src: baseURL + 'diffr-pwa-192x192.png', 41 | sizes: '192x192', 42 | type: 'image/png', 43 | }, 44 | { 45 | src: baseURL + 'diffr-pwa-512x512.png', 46 | sizes: '512x512', 47 | type: 'image/png', 48 | }, 49 | { 50 | src: baseURL + 'diffr-pwa-512x512-maskable.png', 51 | sizes: '512x512', 52 | type: 'image/png', 53 | purpose: 'any maskable', 54 | }, 55 | ], 56 | }, 57 | workbox: { 58 | navigateFallback: baseURL, 59 | globPatterns: ['**/*.{js,css,html,png,svg,ico,ttf,woff,woff2}'], 60 | }, 61 | client: { 62 | installPrompt: true, 63 | }, 64 | devOptions: { 65 | enabled: true, 66 | type: 'module', 67 | }, 68 | }, 69 | googleFonts: { 70 | families: { 71 | 'Fragment+Mono': [400], 72 | Poppins: [400], 73 | }, 74 | }, 75 | imports: { 76 | dirs: ['./stores'], 77 | }, 78 | pinia: { 79 | autoImports: ['defineStore', 'acceptHMRUpdate'], 80 | }, 81 | colorMode: { 82 | storageKey: 'theme', 83 | classSuffix: '', 84 | }, 85 | }) 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-app", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "generate": "nuxt generate", 8 | "preview": "nuxt preview", 9 | "postinstall": "nuxt prepare" 10 | }, 11 | "devDependencies": { 12 | "@nuxtjs/google-fonts": "^3.0.2", 13 | "@nuxtjs/tailwindcss": "^6.8.0", 14 | "@pinia/nuxt": "^0.4.11", 15 | "@tailwindcss/typography": "^0.5.9", 16 | "@types/node": "^22", 17 | "@vite-pwa/nuxt": "^1.0.0", 18 | "monaco-editor": "^0.32.1", 19 | "nuxt": "^3.16.1", 20 | "nuxt-headlessui": "^1.1.4", 21 | "pinia": "^3.0.0", 22 | "prettier": "^3.0.2", 23 | "prettier-plugin-tailwindcss": "^0.5.3", 24 | "sass": "^1.66.1" 25 | }, 26 | "dependencies": { 27 | "@nuxtjs/color-mode": "^3.3.0", 28 | "@vueuse/nuxt": "^13.0.0", 29 | "floating-vue": "^5.0.2", 30 | "lz-string": "^1.5.0", 31 | "nuxt-monaco-editor": "^1.2.3" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pages/about.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 81 | 82 | 95 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 136 | 137 | 332 | 333 | 361 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/diffr-pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/diffr-pwa-192x192.png -------------------------------------------------------------------------------- /public/diffr-pwa-512x512-maskable.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/diffr-pwa-512x512-maskable.png -------------------------------------------------------------------------------- /public/diffr-pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/diffr-pwa-512x512.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loilo/diffr/76573e5059ad2eb7d985ce4b77be1c6edbb1efcf/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/blank.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/cut.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/diffr.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/indent.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/paste.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/screen.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/share.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/split.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/swap.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/icons/trash.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/maskable.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /stores/color-preference-cycle.ts: -------------------------------------------------------------------------------- 1 | export const useColorPreferenceCycleStore = defineStore( 2 | 'color-preference-cycle', 3 | () => { 4 | let colorMode = useColorMode() 5 | 6 | return useCycleList<'light' | 'system' | 'dark'>( 7 | ['light', 'system', 'dark'], 8 | { 9 | initialValue: colorMode.preference as 'light' | 'system' | 'dark', 10 | }, 11 | ) 12 | }, 13 | ) 14 | -------------------------------------------------------------------------------- /stores/encoding-worker.ts: -------------------------------------------------------------------------------- 1 | export const useEncodingWorkerStore = defineStore('encoding-worker', () => { 2 | let worker = new Worker( 3 | new URL('../util/encoding-worker/encoding-worker', import.meta.url), 4 | { 5 | type: 'module', 6 | }, 7 | ) 8 | 9 | return { get: () => worker } 10 | }) 11 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import plugin from 'tailwindcss/plugin' 3 | import typographyPlugin from '@tailwindcss/typography' 4 | 5 | export default { 6 | content: ['./**/*.vue'], 7 | darkMode: 'class', 8 | theme: { 9 | extend: { 10 | fontFamily: { 11 | sans: "Poppins, Avenir, 'Avenir Next LT Pro', Montserrat, Corbel, 'URW Gothic', source-sans-pro, sans-serif", 12 | }, 13 | fontSize: { 14 | icon: '0.8rem', 15 | }, 16 | boxShadow: { 17 | 'inner-sm': 'inset 0 1px 2px 0 rgb(0 0 0 / 0.05)', 18 | 'inner-xs': 'inset 0 1px 1px 0 rgb(0 0 0 / 0.1)', 19 | }, 20 | colors: { 21 | slate: { 22 | '50': '#fafbfc', 23 | '100': '#f1f5f9', 24 | '200': '#e2e8f0', 25 | '300': '#cbd5e1', 26 | '400': '#94a3b8', 27 | '500': '#64748b', 28 | '600': '#475569', 29 | '700': '#334155', 30 | '800': '#1F2A3B', 31 | '900': '#19212F', 32 | '950': '#141821', 33 | }, 34 | blue: { 35 | DEFAULT: '#2fb1f0', 36 | '50': '#f1f9fe', 37 | '100': '#e1f1fd', 38 | '200': '#bce4fb', 39 | '300': '#82cff7', 40 | '400': '#2fb1f0', 41 | '500': '#169ee1', 42 | '600': '#097ec0', 43 | '700': '#09649b', 44 | '800': '#0b5681', 45 | '900': '#0f476b', 46 | '950': '#0a2e47', 47 | }, 48 | teal: { 49 | DEFAULT: '#06a0b5', 50 | '50': '#ecffff', 51 | '100': '#cefeff', 52 | '200': '#a4fbfd', 53 | '300': '#65f4fb', 54 | '400': '#20e4f0', 55 | '500': '#04c8d6', 56 | '600': '#06a0b5', 57 | '700': '#0c7f92', 58 | '800': '#146676', 59 | '900': '#155464', 60 | '950': '#073845', 61 | }, 62 | }, 63 | keyframes: { 64 | 'fade-in': { 65 | '0%': { 66 | opacity: '0', 67 | }, 68 | '100%': { 69 | opacity: '1', 70 | }, 71 | }, 72 | }, 73 | animation: { 74 | 'fade-in': 75 | 'fade-in 500ms ease-in-out var(--animation-delay, 0ms) forwards', 76 | }, 77 | }, 78 | }, 79 | plugins: [ 80 | plugin(function ({ addVariant }) { 81 | addVariant('disabled', '&:is(:disabled, [data-disabled])') 82 | addVariant('group-disabled', '.group:is(:disabled, [data-disabled]) &') 83 | addVariant( 84 | 'not-disabled', 85 | '&:where(:not(:disabled):not([data-disabled]))', 86 | ) 87 | addVariant( 88 | 'group-not-disabled', 89 | '.group:where(:not(:disabled):not([data-disabled]))&', 90 | ) 91 | }), 92 | typographyPlugin, 93 | ], 94 | } satisfies Config 95 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json", 4 | "exclude": ["**/.output/**", "**/dist/**"] 5 | } 6 | -------------------------------------------------------------------------------- /util/encoding-worker/encoding-tools.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decompressFromEncodedURIComponent, 3 | compressToEncodedURIComponent, 4 | } from 'lz-string' 5 | 6 | export function encode(data: any) { 7 | return compressToEncodedURIComponent(JSON.stringify(data)) 8 | } 9 | 10 | export function decode(serialized: any) { 11 | return JSON.parse(decompressFromEncodedURIComponent(serialized)) 12 | } 13 | -------------------------------------------------------------------------------- /util/encoding-worker/encoding-utils.ts: -------------------------------------------------------------------------------- 1 | export type WorkerMessage = 2 | | { 3 | type: 'encode' 4 | id: string 5 | payload: any 6 | } 7 | | { 8 | type: 'decode' 9 | id: string 10 | payload: string 11 | } 12 | 13 | export type EncodingWorkerResponse = { 14 | type: 'encoded' 15 | payload: string 16 | id: string 17 | } 18 | 19 | export type DecodingWorkerResponse = { 20 | type: 'decoded' 21 | payload: T 22 | id: string 23 | } 24 | 25 | export function ensureRecord( 26 | message: unknown, 27 | ): asserts message is Record { 28 | if (typeof message !== 'object' || message === null) { 29 | throw new Error('Invalid message') 30 | } 31 | } 32 | 33 | export function validateMessage( 34 | message: unknown, 35 | ): asserts message is WorkerMessage { 36 | ensureRecord(message) 37 | 38 | if (typeof message.id !== 'string') { 39 | throw new Error('Invalid message id') 40 | } 41 | 42 | switch (message.type) { 43 | case 'encode': 44 | if (typeof message.payload !== 'object') { 45 | throw new Error('Invalid message payload') 46 | } 47 | break 48 | 49 | case 'decode': 50 | if (typeof message.payload !== 'string') { 51 | throw new Error('Invalid message payload') 52 | } 53 | break 54 | 55 | default: 56 | throw new Error('Invalid message type') 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /util/encoding-worker/encoding-worker.ts: -------------------------------------------------------------------------------- 1 | // Handle the actual LZ encoding/decoding 2 | import { encode, decode } from './encoding-tools' 3 | import { 4 | DecodingWorkerResponse, 5 | EncodingWorkerResponse, 6 | validateMessage, 7 | } from './encoding-utils' 8 | 9 | addEventListener('message', event => { 10 | let data = event.data 11 | validateMessage(data) 12 | 13 | switch (data.type) { 14 | case 'encode': 15 | postMessage({ 16 | type: 'encoded', 17 | payload: encode(data.payload), 18 | id: data.id, 19 | } satisfies EncodingWorkerResponse) 20 | break 21 | 22 | case 'decode': 23 | postMessage({ 24 | type: 'decoded', 25 | payload: decode(data.payload), 26 | id: data.id, 27 | } satisfies DecodingWorkerResponse) 28 | break 29 | } 30 | }) 31 | --------------------------------------------------------------------------------