diff --git a/package-lock.json b/package-lock.json index 15587337..9525f3c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "markpad", - "version": "2.6.8", + "version": "2.6.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "markpad", - "version": "2.6.8", + "version": "2.6.11", "license": "MIT", "dependencies": { "@tauri-apps/api": "^2", @@ -23,7 +23,8 @@ "mermaid": "^11.12.2", "monaco-editor": "^0.55.1", "monaco-vim": "^0.4.4", - "node-stream-zip": "^1.15.0" + "node-stream-zip": "^1.15.0", + "yaml": "^2.9.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -32,6 +33,7 @@ "@tauri-apps/cli": "^2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "tsx": "^4.22.4", "typescript": "~5.6.2", "vite": "^6.0.3" } @@ -3006,6 +3008,509 @@ "node": ">=6.10" } }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", @@ -3183,6 +3688,21 @@ "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zimmerframe": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", diff --git a/package.json b/package.json index 3ba20467..86461022 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "dev:installer": "tauri dev -- -- --install", "build": "vite build", "preview": "vite preview", + "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts", + "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/reloadOpenToolbar.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts scripts/toolbarCustomizationWiring.test.ts", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "tauri": "tauri" @@ -28,7 +30,8 @@ "mermaid": "^11.12.2", "monaco-editor": "^0.55.1", "monaco-vim": "^0.4.4", - "node-stream-zip": "^1.15.0" + "node-stream-zip": "^1.15.0", + "yaml": "^2.9.0" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -37,6 +40,7 @@ "@tauri-apps/cli": "^2", "svelte": "^5.0.0", "svelte-check": "^4.0.0", + "tsx": "^4.22.4", "typescript": "~5.6.2", "vite": "^6.0.3" } diff --git a/scripts/editorToolbar.test.ts b/scripts/editorToolbar.test.ts new file mode 100644 index 00000000..293abdf5 --- /dev/null +++ b/scripts/editorToolbar.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + DEFAULT_EDITOR_TOOLBAR_ORDER, + getEditorToolbarAdjacentMove, + getEditorToolbarReorderMove, + getVisibleEditorToolbarTools, + normalizeEditorToolbarHidden, + normalizeEditorToolbarOrder, +} from '../src/lib/utils/editorToolbar.js'; + +test('normalizeEditorToolbarOrder drops unknown ids, deduplicates, and appends new defaults', () => { + assert.deepEqual( + normalizeEditorToolbarOrder([ + 'fmt-heading-1', + 'unknown-tool', + 'fmt-bold', + 'fmt-heading-1', + ]), + [ + 'fmt-heading-1', + 'fmt-bold', + ...DEFAULT_EDITOR_TOOLBAR_ORDER.filter((id) => id !== 'fmt-heading-1' && id !== 'fmt-bold'), + ], + ); +}); + +test('normalizeEditorToolbarHidden keeps only known toolbar ids', () => { + assert.deepEqual( + normalizeEditorToolbarHidden(['fmt-bold', 'unknown-tool', 'fmt-italic']), + ['fmt-bold', 'fmt-italic'], + ); +}); + +test('getVisibleEditorToolbarTools applies saved order and hidden ids', () => { + const tools = getVisibleEditorToolbarTools(['fmt-italic', 'fmt-bold'], ['fmt-bold']); + + assert.equal(tools[0]?.id, 'fmt-italic'); + assert.equal(tools.some((tool) => tool.id === 'fmt-bold'), false); + assert.equal(tools.length, DEFAULT_EDITOR_TOOLBAR_ORDER.length - 1); +}); + +test('toolbar reorder helpers resolve drag and keyboard moves', () => { + const order = ['fmt-bold', 'fmt-italic', 'fmt-link']; + + assert.deepEqual(getEditorToolbarReorderMove(order, 'fmt-link', 'fmt-bold'), { fromIndex: 2, toIndex: 0 }); + assert.deepEqual(getEditorToolbarAdjacentMove(order, 'fmt-italic', 'down'), { fromIndex: 1, toIndex: 2 }); + assert.equal(getEditorToolbarReorderMove(order, 'fmt-bold', 'fmt-bold'), null); + assert.equal(getEditorToolbarAdjacentMove(order, 'fmt-bold', 'up'), null); +}); diff --git a/scripts/exportHtml.test.ts b/scripts/exportHtml.test.ts new file mode 100644 index 00000000..5e9cf59b --- /dev/null +++ b/scripts/exportHtml.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { parseFrontMatter } from '../src/lib/utils/frontMatter.js'; +import { + normalizeAssetPath, + renderStaticFrontMatterPanel, + resolveExportImagePath, + rewriteMarkdownHrefForExport, +} from '../src/lib/utils/exportHtml.js'; + +test('normalizeAssetPath decodes Tauri asset URLs for Windows drive paths and UNC paths', () => { + assert.equal( + normalizeAssetPath('asset://localhost/C:/Users/Daniel/My%20Pictures/diagram.png'), + 'C:/Users/Daniel/My Pictures/diagram.png', + ); + assert.equal( + normalizeAssetPath('asset://localhost/%5C%5Cserver%5Cshare%5Cimage.png'), + '\\\\server\\share\\image.png', + ); +}); + +test('resolveExportImagePath preserves remote/data images and resolves local paths cross-platform', () => { + assert.equal(resolveExportImagePath('https://example.test/a.png', 'C:\\Docs\\note.md'), null); + assert.equal(resolveExportImagePath('data:image/png;base64,abc', '/home/daniel/note.md'), null); + assert.equal(resolveExportImagePath('img/a%20b.png', 'C:\\Docs\\note.md'), 'C:/Docs/img/a b.png'); + assert.equal(resolveExportImagePath('./img/a.png', '/home/daniel/docs/note.md'), '/home/daniel/docs/img/a.png'); + assert.equal(resolveExportImagePath('/home/daniel/assets/a.png', '/home/daniel/docs/note.md'), '/home/daniel/assets/a.png'); +}); + +test('rewriteMarkdownHrefForExport rewrites local Markdown links and preserves query/hash', () => { + assert.equal(rewriteMarkdownHrefForExport('notes/Plan%20A.md#todo'), 'notes/Plan%20A.html#todo'); + assert.equal(rewriteMarkdownHrefForExport('../x/readme.markdown?raw=1#top'), '../x/readme.html?raw=1#top'); + assert.equal(rewriteMarkdownHrefForExport('C:/Docs/spec.mdown#sec'), 'C:/Docs/spec.html#sec'); + assert.equal(rewriteMarkdownHrefForExport('https://example.test/a.md'), 'https://example.test/a.md'); + assert.equal(rewriteMarkdownHrefForExport('mailto:test@example.test'), 'mailto:test@example.test'); +}); + +test('renderStaticFrontMatterPanel exports a collapsed, non-interactive properties block', () => { + const parsed = parseFrontMatter(`--- +type: plan +keywords: [logger, synlog] +draft: false +--- + +# Body +`); + + const html = renderStaticFrontMatterPanel(parsed); + + assert.match(html, /
/); + assert.match(html, //); + assert.match(html, /Properties<\/span>/); + assert.match(html, /logger<\/span>/); + assert.match(html, /synlog<\/span>/); + assert.doesNotMatch(html, /\sopen(?:\s|>)/); + assert.doesNotMatch(html, /<(?:input|button|textarea|select)\b/i); +}); diff --git a/scripts/exportOpenPrompt.test.ts b/scripts/exportOpenPrompt.test.ts new file mode 100644 index 00000000..3aba91bf --- /dev/null +++ b/scripts/exportOpenPrompt.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { askToOpenExportedFile } from '../src/lib/utils/openExportedFile.js'; + +test('askToOpenExportedFile opens the exported file after user confirmation', async () => { + const opened: string[] = []; + const asked: Array<{ message: string; options?: unknown }> = []; + + const result = await askToOpenExportedFile('C:/Docs/export.html', 'HTML', { + ask: async (message, options) => { + asked.push({ message, options }); + return true; + }, + openPath: async (path) => { + opened.push(path); + }, + labels: { + title: 'Open exported file?', + message: 'Open exported HTML file now?', + }, + }); + + assert.equal(result, 'opened'); + assert.deepEqual(opened, ['C:/Docs/export.html']); + assert.equal(asked[0]?.message, 'Open exported HTML file now?'); + assert.deepEqual(asked[0]?.options, { + title: 'Open exported file?', + kind: 'info', + }); +}); + +test('askToOpenExportedFile leaves the file closed when user declines', async () => { + const opened: string[] = []; + + const result = await askToOpenExportedFile('/tmp/export.html', 'HTML', { + ask: async () => false, + openPath: async (path) => { + opened.push(path); + }, + labels: { + title: 'Open exported file?', + message: 'Open exported HTML file now?', + }, + }); + + assert.equal(result, 'declined'); + assert.deepEqual(opened, []); +}); + +test('askToOpenExportedFile reports opener failures without throwing', async () => { + const errors: unknown[] = []; + + const result = await askToOpenExportedFile('/tmp/export.html', 'HTML', { + ask: async () => true, + openPath: async () => { + throw new Error('no default app'); + }, + onError: (error) => { + errors.push(error); + }, + labels: { + title: 'Open exported file?', + message: 'Open exported HTML file now?', + }, + }); + + assert.equal(result, 'failed'); + assert.equal(errors.length, 1); + assert.match(String(errors[0]), /no default app/); +}); diff --git a/scripts/frontMatter.test.ts b/scripts/frontMatter.test.ts new file mode 100644 index 00000000..4e5cbc5f --- /dev/null +++ b/scripts/frontMatter.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + addFrontMatterListItem, + addFrontMatterListItems, + getMarkdownBodyWithoutFrontMatter, + getFrontMatterListItems, + parseFrontMatter, + removeFrontMatterListItem, + updateFrontMatterListItem, + updateFrontMatterField, +} from '../src/lib/utils/frontMatter.js'; + +const planMarkdown = `--- +type: plan +name: dflex-logger-onoff-dir-synlog +title: "Sterowanie loggerami dFLEX: on/off domyslnie OFF" +project: "dflex" +keywords: [logger, synlog, appconfig, onoff, codesite] +created: 2026-06-26T18:29:35+02:00 +status: in-progress +--- + +## Summary + +Body text. +`; + +test('parseFrontMatter extracts top-level YAML metadata and body', () => { + const parsed = parseFrontMatter(planMarkdown); + + assert.equal(parsed.exists, true); + assert.equal(parsed.valid, true); + assert.equal(parsed.body, '## Summary\n\nBody text.\n'); + assert.deepEqual( + parsed.fields.map((field) => [field.key, field.kind, field.displayValue]), + [ + ['type', 'string', 'plan'], + ['name', 'string', 'dflex-logger-onoff-dir-synlog'], + ['title', 'string', 'Sterowanie loggerami dFLEX: on/off domyslnie OFF'], + ['project', 'string', 'dflex'], + ['keywords', 'list', 'logger, synlog, appconfig, onoff, codesite'], + ['created', 'string', '2026-06-26T18:29:35+02:00'], + ['status', 'string', 'in-progress'], + ], + ); +}); + +test('parseFrontMatter ignores thematic breaks outside the first document line', () => { + const markdown = `# Title + +--- + +Body`; + const parsed = parseFrontMatter(markdown); + + assert.equal(parsed.exists, false); + assert.equal(parsed.body, markdown); + assert.equal(getMarkdownBodyWithoutFrontMatter(markdown), markdown); +}); + +test('parseFrontMatter strips invalid front matter from rendered body but keeps the error', () => { + const parsed = parseFrontMatter(`--- +title: [broken +--- + +# Body +`); + + assert.equal(parsed.exists, true); + assert.equal(parsed.valid, false); + assert.match(parsed.error || '', /Flow sequence|unexpected|end/i); + assert.equal(parsed.body, '# Body\n'); + assert.deepEqual(parsed.fields, []); +}); + +test('updateFrontMatterField updates one property without changing markdown body', () => { + const updated = updateFrontMatterField(planMarkdown, 'status', 'done'); + const parsed = parseFrontMatter(updated); + + assert.equal(parsed.exists, true); + assert.equal(parsed.valid, true); + assert.equal(parsed.fields.find((field) => field.key === 'status')?.displayValue, 'done'); + assert.equal(parsed.body, '## Summary\n\nBody text.\n'); +}); + +test('updateFrontMatterField preserves CRLF documents', () => { + const markdown = '---\r\nname: old\r\ntags:\r\n - one\r\n---\r\n\r\nText\r\n'; + const updated = updateFrontMatterField(markdown, 'name', 'new'); + + assert.match(updated, /^---\r\nname: new\r\ntags:/); + assert.equal(parseFrontMatter(updated).body, 'Text\r\n'); +}); + +test('getFrontMatterListItems returns string tag values for YAML lists', () => { + const parsed = parseFrontMatter(planMarkdown); + const keywords = parsed.fields.find((field) => field.key === 'keywords'); + + assert.ok(keywords); + assert.deepEqual(getFrontMatterListItems(keywords), ['logger', 'synlog', 'appconfig', 'onoff', 'codesite']); +}); + +test('front matter tag helpers add, edit, and remove tags predictably', () => { + const original = ['logger', 'synlog']; + + assert.deepEqual(addFrontMatterListItem(original, ' appconfig '), ['logger', 'synlog', 'appconfig']); + assert.deepEqual(addFrontMatterListItem(original, 'logger'), ['logger', 'synlog']); + assert.deepEqual(addFrontMatterListItems(original, [' ', 'synlog', 'codesite, onoff']), ['logger', 'synlog', 'codesite', 'onoff']); + assert.deepEqual(updateFrontMatterListItem(original, 1, ' syslog '), ['logger', 'syslog']); + assert.deepEqual(updateFrontMatterListItem(original, 1, 'logger'), ['logger', 'synlog']); + assert.deepEqual(updateFrontMatterListItem(original, 1, ' '), ['logger', 'synlog']); + assert.deepEqual(removeFrontMatterListItem(original, 0), ['synlog']); +}); + +test('updateFrontMatterField writes edited keywords as a YAML list', () => { + const updated = updateFrontMatterField(planMarkdown, 'keywords', ['logger', 'synlog']); + const parsed = parseFrontMatter(updated); + const keywords = parsed.fields.find((field) => field.key === 'keywords'); + + assert.ok(keywords); + assert.deepEqual(getFrontMatterListItems(keywords), ['logger', 'synlog']); + assert.equal(parsed.body, '## Summary\n\nBody text.\n'); +}); diff --git a/scripts/reloadOpenToolbar.test.ts b/scripts/reloadOpenToolbar.test.ts new file mode 100644 index 00000000..b7830656 --- /dev/null +++ b/scripts/reloadOpenToolbar.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8'); +const editor = readFileSync('src/lib/components/Editor.svelte', 'utf8'); +const tauriLib = readFileSync('src-tauri/src/lib.rs', 'utf8'); + +test('manual reload is wired through menu, titlebar, and F5 with dirty-state protection', () => { + assert.match(markdownViewer, /async function reloadFromDisk\(\)/); + assert.match(markdownViewer, /await canCloseTab\(activeId\)/); + assert.match(markdownViewer, /loadMarkdown\(tab\.path,\s*\{[\s\S]*preserveEditState:\s*true/); + assert.match(markdownViewer, /code === 'F5'[\s\S]*reloadFromDisk\(\)/); + assert.match(markdownViewer, /listen\('menu-file-reload'[\s\S]*reloadFromDisk\(\)/); + assert.match(titleBar, /onreloadFromDisk/); + assert.match(titleBar, /id === 'reload'/); + assert.match(tauriLib, /menu-file-reload/); + assert.match(tauriLib, /\.accelerator\("F5"\)/); +}); + +test('preview-level open shortcut handles Ctrl/Cmd+O outside Monaco without double-handling macOS native menu', () => { + assert.match(markdownViewer, /settings\.osType === 'macos'[\s\S]*key === 'o'[\s\S]*return/); + assert.match(markdownViewer, /cmdOrCtrl[\s\S]*key === 'o'[\s\S]*selectFile\(\)/); +}); + +test('editor toolbar delegates to Monaco actions and exposes expected Markdown commands', () => { + assert.match(markdownViewer, /import EditorToolbar from '\.\/components\/EditorToolbar\.svelte'/); + assert.match(markdownViewer, / editorPane\?\.runEditorAction\(actionId\)\}/); + assert.match(editor, /export function runEditorAction\(actionId: string\)/); + + for (const actionId of [ + 'fmt-bold', + 'fmt-italic', + 'fmt-underline', + 'fmt-inline-code', + 'fmt-code-block', + 'fmt-quote', + 'fmt-heading-1', + 'fmt-heading-2', + 'fmt-heading-3', + 'fmt-bullet-list', + 'fmt-numbered-list', + 'fmt-checklist', + 'fmt-link', + 'insert-table-simple', + ]) { + assert.match(editor, new RegExp(`id: "${actionId}"`)); + } +}); diff --git a/scripts/titlebarToolbar.test.ts b/scripts/titlebarToolbar.test.ts new file mode 100644 index 00000000..3ebe75d5 --- /dev/null +++ b/scripts/titlebarToolbar.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + DEFAULT_TITLEBAR_TOOLBAR_ORDER, + getConfiguredTitlebarToolbarIds, + getTitlebarToolbarAdjacentMove, + getTitlebarToolbarReorderMove, + normalizeTitlebarToolbarHidden, + normalizeTitlebarToolbarOrder, + normalizeTitlebarToolbarPlacement, +} from '../src/lib/utils/titlebarToolbar.js'; + +test('normalizeTitlebarToolbarOrder drops unknown ids, deduplicates, and appends defaults', () => { + assert.deepEqual( + normalizeTitlebarToolbarOrder([ + 'settings', + 'unknown-action', + 'reload', + 'settings', + ]), + [ + 'settings', + 'reload', + ...DEFAULT_TITLEBAR_TOOLBAR_ORDER.filter((id) => id !== 'settings' && id !== 'reload'), + ], + ); +}); + +test('normalizeTitlebarToolbarHidden keeps only known optional actions', () => { + assert.deepEqual( + normalizeTitlebarToolbarHidden(['reload', 'settings', 'unknown-action', 'find']), + ['reload', 'find'], + ); +}); + +test('normalizeTitlebarToolbarPlacement keeps known bar and menu values', () => { + const placement = normalizeTitlebarToolbarPlacement({ + reload: 'menu', + find: 'bar', + settings: 'bar', + unknown: 'bar', + edit: 'hidden', + }); + + assert.equal(placement.reload, 'menu'); + assert.equal(placement.find, 'bar'); + assert.equal(placement.settings, 'bar'); + assert.equal(placement.edit, 'bar'); + assert.equal('unknown' in placement, false); +}); + +test('getConfiguredTitlebarToolbarIds applies context, order, hidden ids, and placement', () => { + const configured = getConfiguredTitlebarToolbarIds( + ['back', 'forward', 'reload', 'find', 'settings', 'open_loc'], + ['settings', 'find', 'reload', 'back', 'forward'], + ['forward'], + { find: 'bar', reload: 'menu', settings: 'menu' }, + ); + + assert.deepEqual(configured.visibleIds, ['settings', 'find', 'reload', 'back']); + assert.deepEqual(configured.barIds, ['find', 'back']); + assert.deepEqual(configured.menuIds, ['settings', 'reload']); +}); + +test('titlebar toolbar reorder helpers resolve drag and keyboard moves', () => { + const order = ['back', 'reload', 'settings']; + + assert.deepEqual(getTitlebarToolbarReorderMove(order, 'settings', 'back'), { fromIndex: 2, toIndex: 0 }); + assert.deepEqual(getTitlebarToolbarAdjacentMove(order, 'reload', 'down'), { fromIndex: 1, toIndex: 2 }); + assert.equal(getTitlebarToolbarReorderMove(order, 'back', 'back'), null); + assert.equal(getTitlebarToolbarAdjacentMove(order, 'back', 'up'), null); +}); diff --git a/scripts/toolbarCustomizationWiring.test.ts b/scripts/toolbarCustomizationWiring.test.ts new file mode 100644 index 00000000..89942afe --- /dev/null +++ b/scripts/toolbarCustomizationWiring.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const markdownViewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); +const settingsComponent = readFileSync('src/lib/components/Settings.svelte', 'utf8'); +const settingsStore = readFileSync('src/lib/stores/settings.svelte.ts', 'utf8'); +const titleBar = readFileSync('src/lib/components/TitleBar.svelte', 'utf8'); + +test('editor toolbar receives order and hidden configuration from settings', () => { + assert.match(markdownViewer, /toolbarOrder=\{settings\.editorToolbarOrder\}/); + assert.match(markdownViewer, /toolbarHidden=\{settings\.editorToolbarHidden\}/); +}); + +test('settings expose draggable toolbar customization controls', () => { + assert.match(settingsComponent, /settings\.reorderEditorToolbarTool/); + assert.match(settingsComponent, /draggable="true"/); + assert.match(settingsComponent, /settings\.setEditorToolbarToolVisible/); + assert.match(settingsComponent, /settings\.resetEditorToolbar/); +}); + +test('settings separate editor toolbar and application titlebar toolbar customization', () => { + assert.match(settingsComponent, /activeCategory === 'toolbars'/); + assert.match(settingsComponent, /settings\.reorderTitlebarToolbarAction/); + assert.match(settingsComponent, /settings\.setTitlebarToolbarActionVisible/); + assert.match(settingsComponent, /settings\.setTitlebarToolbarActionPlacement/); + assert.match(settingsComponent, /settings\.resetTitlebarToolbar/); + assert.match(settingsStore, /titlebar\.toolbarOrder/); + assert.match(settingsStore, /titlebar\.toolbarHidden/); + assert.match(settingsStore, /titlebar\.toolbarPlacement/); +}); + +test('titlebar applies user toolbar settings before rendering bar and more menu', () => { + assert.match(titleBar, /getConfiguredTitlebarToolbarIds/); + assert.match(titleBar, /settings\.titlebarToolbarOrder/); + assert.match(titleBar, /configuredActionIds\.barIds/); + assert.match(titleBar, /configuredActionIds\.menuIds/); + assert.doesNotMatch(titleBar, /const inlineIds/); +}); + +test('settings modal is resizable with size constraints', () => { + assert.match(settingsComponent, /\.settings-modal[\s\S]*resize:\s*both/); + assert.match(settingsComponent, /\.settings-modal[\s\S]*min-width:/); + assert.match(settingsComponent, /\.settings-modal[\s\S]*max-height:/); +}); + +test('top toolbar overflow no longer includes Open file location action', () => { + assert.doesNotMatch(titleBar, /list\.push\('open_loc'\)/); +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 572eec1c..9eb05ede 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -113,6 +113,27 @@ fn atomic_write(target: &Path, bytes: &[u8]) -> std::io::Result<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_data_url_uses_mime_from_extension_case_insensitively() { + assert_eq!(mime_type_for_export_path(Path::new("diagram.PNG")), "image/png"); + assert_eq!(mime_type_for_export_path(Path::new("photo.JpEg")), "image/jpeg"); + assert_eq!(mime_type_for_export_path(Path::new("vector.svg")), "image/svg+xml"); + assert_eq!(mime_type_for_export_path(Path::new("unknown.bin")), "application/octet-stream"); + } + + #[test] + fn export_data_url_encodes_bytes_with_mime() { + assert_eq!( + file_bytes_to_data_url("image/png", b"Markpad"), + "data:image/png;base64,TWFya3BhZA==", + ); + } +} + struct WatcherState { watcher: Mutex>, } @@ -314,6 +335,41 @@ fn read_file_content(path: String) -> Result { fs::read_to_string(path).map_err(|e| e.to_string()) } +fn mime_type_for_export_path(path: &Path) -> &'static str { + match path + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("png") => "image/png", + Some("jpg") | Some("jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("svg") => "image/svg+xml", + Some("bmp") => "image/bmp", + Some("ico") => "image/x-icon", + Some("avif") => "image/avif", + _ => "application/octet-stream", + } +} + +fn file_bytes_to_data_url(mime_type: &str, bytes: &[u8]) -> String { + use base64::{engine::general_purpose, Engine as _}; + format!( + "data:{};base64,{}", + mime_type, + general_purpose::STANDARD.encode(bytes) + ) +} + +#[tauri::command] +fn read_file_as_data_url(path: String) -> Result { + let bytes = fs::read(&path).map_err(|e| e.to_string())?; + let mime_type = mime_type_for_export_path(Path::new(&path)); + Ok(file_bytes_to_data_url(mime_type, &bytes)) +} + #[tauri::command] fn save_file_content(path: String, content: String) -> Result<(), String> { atomic_write(Path::new(&path), content.as_bytes()).map_err(|e| e.to_string()) @@ -632,6 +688,9 @@ fn clipboard_read_text() -> Result { #[tauri::command] fn clipboard_read_image(macos_image_scaling: bool) -> Result { + #[cfg(not(target_os = "macos"))] + let _ = macos_image_scaling; + let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; let image = clipboard.get_image().map_err(|e| e.to_string())?; @@ -993,6 +1052,11 @@ pub fn run() { .accelerator("CmdOrCtrl+O") .build(app)?, ) + .item( + &MenuItemBuilder::with_id("menu-file-reload", "Reload from Disk") + .accelerator("F5") + .build(app)?, + ) .item( &MenuItemBuilder::with_id("menu-file-close", "Close") .accelerator("CmdOrCtrl+W") @@ -1102,6 +1166,7 @@ pub fn run() { render_markdown, send_markdown_path, read_file_content, + read_file_as_data_url, save_file_content, save_file_binary, get_app_mode, diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 12077c73..4e357e87 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -5,13 +5,14 @@ import { onMount, tick, untrack } from 'svelte'; import { fly } from 'svelte/transition'; import { cubicOut } from 'svelte/easing'; - import { openUrl } from '@tauri-apps/plugin-opener'; + import { openPath, openUrl } from '@tauri-apps/plugin-opener'; import { open, save, ask } from '@tauri-apps/plugin-dialog'; import Installer from './Installer.svelte'; import Uninstaller from './Uninstaller.svelte'; import Settings from './components/Settings.svelte'; import TitleBar from './components/TitleBar.svelte'; import Editor from './components/Editor.svelte'; + import EditorToolbar from './components/EditorToolbar.svelte'; import Modal from './components/Modal.svelte'; import UpdateDialog from './components/UpdateDialog.svelte'; import { updateStore } from './stores/update.svelte.js'; @@ -20,6 +21,7 @@ import Toast from './components/Toast.svelte'; import FindBar from './components/FindBar.svelte'; import { exportAsHtml as _exportHtml, exportAsPdf } from './utils/export'; + import { askToOpenExportedFile } from './utils/openExportedFile.js'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { processMarkdownHtml } from './utils/markdown'; import { @@ -82,6 +84,7 @@ import { t } from './utils/i18n.js'; handleDroppedFile: (path: string, x: number, y: number) => Promise; updateDragCaret: (x: number, y: number) => void; hideDragCaret: () => void; + runEditorAction: (actionId: string) => void; undo: () => void; redo: () => void; revealHeader: (text: string) => void; @@ -1607,12 +1610,30 @@ import { t } from './utils/i18n.js'; async function exportAsHtml() { const tab = tabManager.activeTab; - await _exportHtml({ - htmlContent: htmlContent, - markdownBody, + const result = await _exportHtml({ + rawContent, tabTitle: tab?.title || '', tabPath: tab?.path || '', }); + if (result?.missingImages) { + addToast(`Exported HTML, but ${result.missingImages} local image(s) could not be embedded.`, 'warning'); + } + if (result?.path) { + const openResult = await askToOpenExportedFile(result.path, 'HTML', { + ask, + openPath, + labels: { + title: t('modal.openExportedFileTitle', settings.language), + message: t('modal.openExportedHtmlMessage', settings.language), + }, + onError: (error) => { + console.error('Failed to open exported HTML file', result.path, error); + }, + }); + if (openResult === 'failed') { + addToast(t('toast.openExportedFileFailed', settings.language), 'error'); + } + } } function handleNewFile() { @@ -1631,6 +1652,20 @@ import { t } from './utils/i18n.js'; if (selected && typeof selected === 'string') loadMarkdown(selected); } + async function reloadFromDisk() { + const activeId = tabManager.activeTabId; + const tab = tabManager.activeTab; + if (!activeId || !tab?.path) return; + if (!(await canCloseTab(activeId))) return; + + await loadMarkdown(tab.path, { + preserveEditState: true, + skipTabManagement: true, + resetScrollHistory: true, + }); + addToast('Reloaded from disk', 'info'); + } + function toggleHome() { showHome = !showHome; } @@ -2030,10 +2065,16 @@ import { t } from './utils/i18n.js'; if (key === 'w') return; // → menu-file-close if (key === 's') return; // → menu-file-save if (key === 't') return; // → menu-file-new + if (key === 'o') return; // → menu-file-open } const isSplit = tabManager.activeTab?.isSplit; + if (!cmdOrCtrl && !e.shiftKey && !e.altKey && code === 'F5') { + e.preventDefault(); + reloadFromDisk(); + return; + } if (cmdOrCtrl && key === 'w') { e.preventDefault(); closeFile(); @@ -2046,12 +2087,14 @@ import { t } from './utils/i18n.js'; e.preventDefault(); tabManager.addHomeTab(); } - if (cmdOrCtrl && key === 'q') { + if (cmdOrCtrl && !e.shiftKey && !e.altKey && key === 'o') { e.preventDefault(); - import('@tauri-apps/api/window').then(({ getCurrentWindow }) => { - getCurrentWindow().close(); - }); + selectFile(); } + if (cmdOrCtrl && key === 'q') { + e.preventDefault(); + getCurrentWindow().close(); + } if (cmdOrCtrl && !e.shiftKey && !e.altKey && (code === 'Backslash' || code === 'IntlBackslash')) { e.preventDefault(); if (tabManager.activeTabId) toggleSplitView(tabManager.activeTabId, true); @@ -2316,12 +2359,11 @@ import { t } from './utils/i18n.js'; let unlisteners: (() => void)[] = []; - invoke('show_window').catch(console.error); + invoke('show_window').catch(console.error); - const init = async () => { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const appWindow = getCurrentWindow(); - const appMode = (await invoke('get_app_mode')) as any; + const init = async () => { + const appWindow = getCurrentWindow(); + const appMode = (await invoke('get_app_mode')) as any; if (settings.restoreStateOnReopen) { const savedData = localStorage.getItem('savedTabsData'); @@ -2461,6 +2503,7 @@ import { t } from './utils/i18n.js'; unlisteners.push(await listen('menu-app-quit', () => appExit())); unlisteners.push(await listen('menu-file-new', () => handleNewFile())); unlisteners.push(await listen('menu-file-open', () => selectFile())); + unlisteners.push(await listen('menu-file-reload', () => reloadFromDisk())); unlisteners.push(await listen('menu-file-close', () => closeFile())); unlisteners.push(await listen('menu-file-save', () => { if (isEditing || tabManager.activeTab?.isSplit) saveContent(); @@ -2668,6 +2711,7 @@ import { t } from './utils/i18n.js'; onopenFile={selectFile} onsaveFile={saveContent} onsaveFileAs={saveContentAs} + onreloadFromDisk={reloadFromDisk} onexportHtml={exportAsHtml} onexportPdf={exportAsPdf} onexit={appExit} @@ -2710,6 +2754,7 @@ import { t } from './utils/i18n.js'; onopenFile={selectFile} onsaveFile={saveContent} onsaveFileAs={saveContentAs} + onreloadFromDisk={reloadFromDisk} onexportHtml={exportAsHtml} onexportPdf={exportAsPdf} onexit={appExit} @@ -2755,6 +2800,11 @@ import { t } from './utils/i18n.js';
{#if isEditing || isSplit} + editorPane?.runEditorAction(actionId)} /> string[], + ) => { + const selection = editor.getSelection(); + const model = editor.getModel(); + if (!selection || !model) return; + + let endLine = selection.endLineNumber; + if (selection.endColumn === 1 && endLine > selection.startLineNumber) { + endLine -= 1; + } + + const range = new monaco.Range( + selection.startLineNumber, + 1, + endLine, + model.getLineMaxColumn(endLine), + ); + const lines = model.getValueInRange(range).split(/\r?\n/); + editor.executeEdits(source, [ + { + range, + text: transform(lines).join(model.getEOL()), + forceMoveMarkers: true, + }, + ]); + }; + + const toggleLinePrefix = (source: string, prefix: string, pattern: RegExp) => { + transformSelectedLines(source, (lines) => { + const contentLines = lines.filter((line) => line.trim().length > 0); + const shouldRemove = contentLines.length > 0 && contentLines.every((line) => pattern.test(line)); + return lines.map((line) => { + if (!line.trim()) return line; + if (shouldRemove) return line.replace(pattern, ""); + return `${prefix}${line}`; + }); + }); + }; + + const toggleHeading = (level: number) => { + const heading = "#".repeat(level); + transformSelectedLines(`fmt-heading-${level}`, (lines) => { + const pattern = new RegExp(`^${heading}\\s+`); + const contentLines = lines.filter((line) => line.trim().length > 0); + const shouldRemove = contentLines.length > 0 && contentLines.every((line) => pattern.test(line)); + return lines.map((line) => { + if (!line.trim()) return line; + const withoutHeading = line.replace(/^#{1,6}\s+/, ""); + return shouldRemove ? withoutHeading : `${heading} ${withoutHeading}`; + }); + }); + }; + + const toggleNumberedList = () => { + transformSelectedLines("fmt-numbered-list", (lines) => { + const contentLines = lines.filter((line) => line.trim().length > 0); + const shouldRemove = contentLines.length > 0 && contentLines.every((line) => /^\d+\.\s+/.test(line)); + let itemIndex = 1; + return lines.map((line) => { + if (!line.trim()) return line; + if (shouldRemove) return line.replace(/^\d+\.\s+/, ""); + return `${itemIndex++}. ${line.replace(/^(?:[-*+]|\d+\.)\s+/, "")}`; + }); + }); + }; + + const wrapAsCodeBlock = () => { + const selection = editor.getSelection(); + const model = editor.getModel(); + if (!selection || !model) return; + + const text = model.getValueInRange(selection); + const block = text.startsWith("```\n") && text.endsWith("\n```") + ? text.slice(4, -4).replace(/\n$/, "") + : `\`\`\`\n${text}\n\`\`\``; + editor.executeEdits("fmt-code-block", [ + { range: selection, text: block, forceMoveMarkers: true }, + ]); + }; + + const insertLink = () => { + const selection = editor.getSelection(); + const model = editor.getModel(); + if (!selection || !model) return; + + const text = model.getValueInRange(selection); + const label = text || "link text"; + const link = `[${label}](url)`; + editor.executeEdits("fmt-link", [ + { range: selection, text: link, forceMoveMarkers: true }, + ]); + }; + editor.addAction({ id: "fmt-bold", label: t('menu.bold', uiLanguage), @@ -454,6 +548,66 @@ run: () => toggleFormat("|", "tag"), }); + editor.addAction({ + id: "fmt-inline-code", + label: "Inline Code", + run: () => toggleFormat("`"), + }); + + editor.addAction({ + id: "fmt-code-block", + label: "Code Block", + run: () => wrapAsCodeBlock(), + }); + + editor.addAction({ + id: "fmt-quote", + label: "Quote", + run: () => toggleLinePrefix("fmt-quote", "> ", /^>\s+/), + }); + + editor.addAction({ + id: "fmt-heading-1", + label: "Heading 1", + run: () => toggleHeading(1), + }); + + editor.addAction({ + id: "fmt-heading-2", + label: "Heading 2", + run: () => toggleHeading(2), + }); + + editor.addAction({ + id: "fmt-heading-3", + label: "Heading 3", + run: () => toggleHeading(3), + }); + + editor.addAction({ + id: "fmt-bullet-list", + label: "Bullet List", + run: () => toggleLinePrefix("fmt-bullet-list", "- ", /^[-*+]\s+/), + }); + + editor.addAction({ + id: "fmt-numbered-list", + label: "Numbered List", + run: () => toggleNumberedList(), + }); + + editor.addAction({ + id: "fmt-checklist", + label: "Checklist", + run: () => toggleLinePrefix("fmt-checklist", "- [ ] ", /^[-*+]\s+\[[ xX]\]\s+/), + }); + + editor.addAction({ + id: "fmt-link", + label: "Link", + run: () => insertLink(), + }); + editor.addAction({ id: "insert-table-simple", label: t('menu.insertTable', uiLanguage), @@ -995,9 +1149,17 @@ $effect(() => { if (editor && settings.vimMode && vimStatusNode) { - const vim = initVimMode(editor, vimStatusNode); + let disposed = false; + let vim: { dispose: () => void } | null = null; + const currentEditor = editor; + const currentStatusNode = vimStatusNode; + import("monaco-vim").then(({ initVimMode }) => { + if (disposed) return; + vim = initVimMode(currentEditor, currentStatusNode); + }); return () => { - vim.dispose(); + disposed = true; + vim?.dispose(); }; } }); @@ -1124,6 +1286,12 @@ editor.getAction("actions.find")?.run(); } + export function runEditorAction(actionId: string) { + if (!editor) return; + editor.focus(); + editor.getAction(actionId)?.run(); + } + export const getValue = () => editor?.getValue() || ""; export const setValue = (val: string) => editor?.setValue(val); export const focus = () => editor?.focus(); diff --git a/src/lib/components/EditorToolbar.svelte b/src/lib/components/EditorToolbar.svelte new file mode 100644 index 00000000..224628c3 --- /dev/null +++ b/src/lib/components/EditorToolbar.svelte @@ -0,0 +1,92 @@ + + + + + diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte index dfce2157..1a856cc4 100644 --- a/src/lib/components/Settings.svelte +++ b/src/lib/components/Settings.svelte @@ -1,11 +1,14 @@