From 9d0146b890cf56bffd015873ec1bb6c208497f0d Mon Sep 17 00:00:00 2001 From: Daniel Grabowski Date: Sun, 28 Jun 2026 17:37:15 +0200 Subject: [PATCH] feat(workflows): improve export, reload, and toolbars HTML export now renders from the Markdown source, rewrites local Markdown links, embeds local images as data URLs, and includes frontmatter metadata as a static properties block. Add a reload-from-disk action through the titlebar, native menu, and F5 with dirty-state protection. The editor formatting toolbar and the application titlebar toolbar can now be configured from settings, including visibility and order. The export path asks whether to open the saved HTML file in the system default app. PDF export still delegates to the system print dialog because that path does not expose the saved file path. fixes #154 fixes #163 fixes #185 --- package-lock.json | 526 ++++++++++++++++++++- package.json | 6 +- scripts/editorToolbar.test.ts | 51 ++ scripts/exportHtml.test.ts | 58 +++ scripts/exportOpenPrompt.test.ts | 71 +++ scripts/frontMatter.test.ts | 124 +++++ scripts/reloadOpenToolbar.test.ts | 50 ++ scripts/titlebarToolbar.test.ts | 73 +++ scripts/toolbarCustomizationWiring.test.ts | 49 ++ src-tauri/src/lib.rs | 65 +++ src/lib/MarkdownViewer.svelte | 76 ++- src/lib/components/Editor.svelte | 174 ++++++- src/lib/components/EditorToolbar.svelte | 92 ++++ src/lib/components/Settings.svelte | 429 ++++++++++++++++- src/lib/components/TitleBar.svelte | 106 ++++- src/lib/components/ZoomOverlay.svelte | 1 + src/lib/stores/settings.svelte.ts | 117 +++++ src/lib/utils/editorToolbar.ts | 120 +++++ src/lib/utils/export.ts | 102 +++- src/lib/utils/exportHtml.ts | 135 ++++++ src/lib/utils/frontMatter.ts | 223 +++++++++ src/lib/utils/i18n.ts | 38 +- src/lib/utils/openExportedFile.ts | 44 ++ src/lib/utils/titlebarToolbar.ts | 161 +++++++ vite.config.js | 3 + 25 files changed, 2826 insertions(+), 68 deletions(-) create mode 100644 scripts/editorToolbar.test.ts create mode 100644 scripts/exportHtml.test.ts create mode 100644 scripts/exportOpenPrompt.test.ts create mode 100644 scripts/frontMatter.test.ts create mode 100644 scripts/reloadOpenToolbar.test.ts create mode 100644 scripts/titlebarToolbar.test.ts create mode 100644 scripts/toolbarCustomizationWiring.test.ts create mode 100644 src/lib/components/EditorToolbar.svelte create mode 100644 src/lib/utils/editorToolbar.ts create mode 100644 src/lib/utils/exportHtml.ts create mode 100644 src/lib/utils/frontMatter.ts create mode 100644 src/lib/utils/openExportedFile.ts create mode 100644 src/lib/utils/titlebarToolbar.ts 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 @@