diff --git a/.gitignore b/.gitignore index edc30c69..9452286e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ .vite debug coverage +output +tmp-vue-native.png +tmp-vue-native-fixed.png dist docs/others node_modules diff --git a/README.md b/README.md index f8b4f97f..ef1bcfc1 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,39 @@ npm run build # production build → dist/ Built with Svelte 5, TypeScript, Vite and TipTap 3 (ProseMirror). +## Vue 3 package + +The repository also builds the browser-only Vue 3 integration package +`edentext-vue`. It mounts the original EdenText `App.svelte` unchanged and +exposes only a thin command bridge for new/open/save-as and language settings. +The Vue layer does not rebuild the Ribbon or maintain a second document model. + +```bash +npm install edentext-vue vue +``` + +```vue + + + +``` + +Build and inspect the publishable package with `npm run build:lib` and +`npm pack ./packages/edentext-vue --dry-run`. See +[`docs/edentext-vue.md`](docs/edentext-vue.md) for the Vue API and npm release +workflow. + ## Self-hosting There is no backend and no state on the server; documents stay in the browser. diff --git a/docs/edentext-vue.md b/docs/edentext-vue.md new file mode 100644 index 00000000..ba6e24da --- /dev/null +++ b/docs/edentext-vue.md @@ -0,0 +1,255 @@ +# EdenText Vue 3 npm 包使用与发布指南 + +`edentext-vue` 是 EdenText 原生 Web 应用的 Vue 3 薄封装。Vue 组件只负责挂载和销毁原生 `App.svelte`,以及把少量宿主命令转发给原生 EdenText:新建、打开、保存、另存、界面语言和文档默认语言。 + +Ribbon、编辑器、分页、页眉页脚、表格、图片、审阅、查找替换、模板、ODT/DOCX/PDF 等功能全部来自原生 EdenText,不在 Vue 层重新实现。 + +## 对外 API + +### 安装 + +```bash +npm install edentext-vue vue +``` + +`vue` 是 peer dependency。该包只支持 Vue 3 和浏览器,不支持 SSR、Node 服务端转换,也不包含 React 入口。 + +### 最小调用 + +宿主必须给组件一个明确高度: + +```vue + + + + + +``` + +组件内部显示的是 EdenText 原生 UI,不需要也不应该在 Vue 页面外部重新做一个工具栏。 + +### Props + +```ts +interface EdentextEditorProps { + /** 原生 EdenText 界面语言。可选:en、de、es、fr、pt、ru、ja、zh-Hans、zh-Hant。 */ + locale?: UiLocale; + /** 文档默认语言、拼写检查语言和导出语言。可选值由 EdenText 的语言表决定。 */ + documentLanguage?: string; + /** 挂载完成后调用一次原生“新建文档”,用于打开空文档。 */ + initialNewDocument?: boolean; + /** 可选:EdenText.png 和 favicon.svg 所在的静态资源目录。 */ + assetBaseUrl?: string; +} +``` + +`locale` 和 `documentLanguage` 在组件挂载后发生变化时,会调用原生 EdenText 的语言设置函数,不会重建编辑器,也不会替换正文。 + +### 实例 API + +```ts +interface EdentextEditorApi { + newDocument(): void; + + /** 打开原生文件选择器。 */ + openFile(): Promise; + + /** 复用原生导入器打开 ODT/DOCX。filename 用于判断格式和显示文档名。 */ + openDocument( + source: File | Blob | ArrayBuffer | Uint8Array, + filename?: string, + ): Promise; + + /** 调用原生 Save;已打开文件会保存回原文件,未保存文件会进入原生保存流程。 */ + save(): Promise; + + /** 调用原生 Save As;由原生 UI 选择目标文件或触发浏览器下载。 */ + saveAs(format: 'odt' | 'docx'): Promise; + + /** 调用原生导出流程;ODT/DOCX 走 Save As,PDF 走 EdenText 原生 PDF 导出。 */ + exportDocument(format: 'odt' | 'docx' | 'pdf'): Promise; + + setUiLocale(locale: UiLocale): void; + setDocumentLanguage(language: string): void; + focus(): void; + destroy(): void; +} +``` + +通过 Vue 模板 ref 使用: + +```vue + + + +``` + +`ready` 事件只在原生 EdenText editor 实例真正创建完成后触发;在此之前不要调用实例方法。`error` 事件报告挂载错误。组件卸载时会自动卸载 Svelte 原生应用和其资源;通常不需要手动调用 `destroy()`,手动调用只适用于宿主明确要提前销毁的场景。 + +### 新建、打开、另存和语言 + +```ts +editor.value?.newDocument(); +await editor.value?.openFile(); +await editor.value?.openDocument(bytes, '合同.docx'); +await editor.value?.save(); +await editor.value?.saveAs('odt'); +await editor.value?.exportDocument('pdf'); +editor.value?.setUiLocale('zh-Hans'); +editor.value?.setDocumentLanguage('zh-CN'); +``` + +这些方法都调用原生 EdenText 已有的命令,因此确认弹窗、密码处理、最近文件、格式识别、File System Access API 和浏览器下载行为保持原生一致。 + +Vue 层不提供 `v-model`,也不暴露 TipTap、ProseMirror、Svelte 实例或另外一套 JSON 文档状态。宿主需要把文件上传到服务端时,可以在调用 `openDocument` 前自行读取文件;导出文件则通过原生 `save`/`saveAs` 流程完成。 + +## 仓库内运行 Vue demo + +```bash +npm install +npm run build:lib +npm run demo:vue +``` + +然后访问 Vite 输出的本地地址。demo 只创建一个 Vue 容器并挂载 `EdentextEditor`,不创建额外 header 或工具栏;`initial-new-document` 会让原生 EdenText 打开空文档。 + +npm 部署时如果使用包内 logo/icon,建议把 `dist/assets/EdenText.png` 和 `dist/assets/favicon.svg` 复制到宿主静态目录,并通过 `asset-base-url` 指向该目录。 + +也可以只构建 demo: + +```bash +npm run build:demo:vue +``` + +## 构建 npm 包 + +根目录执行: + +```bash +npm install +npm run check:lib +npm run build:lib +npm pack ./packages/edentext-vue --dry-run +``` + +`build:lib` 会完成三件事: + +1. 用 `vite.lib.config.ts` 构建 ESM 入口; +2. 用 `vue-tsc` 输出 `dist/vue/index.d.ts` 等声明文件; +3. 把字体、Hunspell 词典、同义词库和许可证资源复制到 `dist/assets/`。 + +产物位于 `packages/edentext-vue/dist/`,包的导出约定为: + +```json +{ + "name": "edentext-vue", + "peerDependencies": { "vue": "^3.4.0" }, + "exports": { + ".": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css", + "./assets/*": "./dist/assets/*" + } +} +``` + +## 发布到 npm + +先登录并确认包名可用: + +```bash +npm login +npm whoami +npm view edentext-vue name +``` + +修改 `packages/edentext-vue/package.json` 的版本号,然后执行发布前检查: + +```bash +npm run check +npm run check:lib +npm test +npm run build:app +npm run build:lib +npm pack ./packages/edentext-vue --dry-run +``` + +确认 dry-run 内容后发布: + +```bash +npm publish ./packages/edentext-vue --access public +``` + +`prepack` 会把根目录的 `LICENSE` 和 `THIRD-PARTY-NOTICES.md` 放入包目录,`postpack` 会清理临时文件。发布前应确认 tarball 至少包含: + +- `dist/index.js` +- `dist/vue/index.d.ts` +- `dist/style.css` +- `dist/assets/*` +- `LICENSE` +- `THIRD-PARTY-NOTICES.md` + +发布后用干净的 Vue 3 项目验证: + +```bash +npm install vue edentext-vue +``` + +验证原生 Ribbon、空文档、输入编辑、原生打开、保存/另存、UI 国际化、内容默认语言、字体和词典资源均正常加载。 + +## 设计边界 + +- Vue 是集成入口,不是第二个编辑器实现。 +- 原生 EdenText UI 和功能继续由 `src/App.svelte` 维护。 +- 不发布 React 组件,不发布 `@edentext/core`。 +- 浏览器端运行;不支持 SSR 和 Node 服务端文档转换。 +- 许可证为 `AGPL-3.0-only`。 diff --git a/examples/edentext-vue-demo/README.md b/examples/edentext-vue-demo/README.md new file mode 100644 index 00000000..7fe055ba --- /dev/null +++ b/examples/edentext-vue-demo/README.md @@ -0,0 +1,19 @@ +# EdenText Vue Demo + +This is a standalone Vue 3 demo that consumes the local `edentext-vue` package +through the same package entry used by npm consumers. + +```bash +npm install --no-package-lock +npm run dev +``` + +From the repository root, the equivalent command is: + +```bash +npm run demo:vue +``` + +Open the printed Vite URL. The demo renders only the Vue host and the native +EdenText application. Its original Ribbon, editor, pagination and status bar +are visible, and `initial-new-document` starts with an empty document. diff --git a/examples/edentext-vue-demo/index.html b/examples/edentext-vue-demo/index.html new file mode 100644 index 00000000..b6728103 --- /dev/null +++ b/examples/edentext-vue-demo/index.html @@ -0,0 +1,13 @@ + + + + + + + EdenText Vue Demo + + +
+ + + diff --git a/examples/edentext-vue-demo/package.json b/examples/edentext-vue-demo/package.json new file mode 100644 index 00000000..535f74f1 --- /dev/null +++ b/examples/edentext-vue-demo/package.json @@ -0,0 +1,21 @@ +{ + "name": "edentext-vue-demo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "typecheck": "vue-tsc --noEmit" + }, + "dependencies": { + "edentext-vue": "file:../../packages/edentext-vue", + "vue": "^3.5.43" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.9", + "typescript": "^6.0.3", + "vite": "^8.0.14", + "vue-tsc": "^3.3.11" + } +} diff --git a/examples/edentext-vue-demo/public/assets/EdenText.png b/examples/edentext-vue-demo/public/assets/EdenText.png new file mode 100644 index 00000000..8bef1190 Binary files /dev/null and b/examples/edentext-vue-demo/public/assets/EdenText.png differ diff --git a/examples/edentext-vue-demo/public/assets/favicon.svg b/examples/edentext-vue-demo/public/assets/favicon.svg new file mode 100644 index 00000000..e3ea910d --- /dev/null +++ b/examples/edentext-vue-demo/public/assets/favicon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/edentext-vue-demo/public/favicon.svg b/examples/edentext-vue-demo/public/favicon.svg new file mode 100644 index 00000000..4ada9567 --- /dev/null +++ b/examples/edentext-vue-demo/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/examples/edentext-vue-demo/src/App.vue b/examples/edentext-vue-demo/src/App.vue new file mode 100644 index 00000000..bd193ee5 --- /dev/null +++ b/examples/edentext-vue-demo/src/App.vue @@ -0,0 +1,31 @@ + + + diff --git a/examples/edentext-vue-demo/src/demo.css b/examples/edentext-vue-demo/src/demo.css new file mode 100644 index 00000000..4e2f9340 --- /dev/null +++ b/examples/edentext-vue-demo/src/demo.css @@ -0,0 +1,23 @@ +html, +body, +#app { + width: 100%; + height: 100%; + min-width: 960px; + margin: 0; +} + +body { + overflow: hidden; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +.demo-root { + width: 100%; + height: 100%; +} diff --git a/examples/edentext-vue-demo/src/main.ts b/examples/edentext-vue-demo/src/main.ts new file mode 100644 index 00000000..59b43e7b --- /dev/null +++ b/examples/edentext-vue-demo/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './demo.css'; + +createApp(App).mount('#app'); diff --git a/examples/edentext-vue-demo/vite.config.ts b/examples/edentext-vue-demo/vite.config.ts new file mode 100644 index 00000000..8f69f842 --- /dev/null +++ b/examples/edentext-vue-demo/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +const demoRoot = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + plugins: [vue()], + server: { + fs: { + allow: [resolve(demoRoot, '../..')], + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index eebdd8af..9b26da11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", + "@vitejs/plugin-vue": "^6.0.9", "@vitest/coverage-v8": "^4.1.10", "dictionary-de": "^3.0.0", "dictionary-en": "^4.0.0", @@ -58,7 +59,9 @@ "svelte-check": "^4.7.1", "typescript": "^6.0.3", "vite": "^8.0.14", - "vitest": "^4.1.9" + "vitest": "^4.1.9", + "vue": "^3.5.43", + "vue-tsc": "^3.3.11" } }, "node_modules/@asamuzakjp/css-color": { @@ -1219,6 +1222,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, "node_modules/@vitest/coverage-v8": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", @@ -1363,6 +1383,185 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/language-core": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.3.11.tgz", + "integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.2.1", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "dev": true, + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1376,6 +1575,13 @@ "node": ">=0.4.0" } }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "dev": true, + "license": "MIT" + }, "node_modules/aria-query": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1541,6 +1747,13 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2464,10 +2677,17 @@ "node": ">=4" } }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ { @@ -2541,6 +2761,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2589,9 +2816,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -2609,7 +2836,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3434,6 +3661,52 @@ } } }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.3.11.tgz", + "integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.3.11" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", diff --git a/package.json b/package.json index cdfa7761..6c7bf14c 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,14 @@ "scripts": { "dev": "vite --host", "build": "vite build", + "build:app": "vite build", + "build:lib": "vite build --config vite.lib.config.ts && vue-tsc -p tsconfig.lib.json --declaration --emitDeclarationOnly --outDir packages/edentext-vue/dist", + "demo:vue": "npm --prefix examples/edentext-vue-demo run dev", + "build:demo:vue": "npm --prefix examples/edentext-vue-demo run build", "preview": "vite preview", "check": "svelte-check --tsconfig ./tsconfig.json", + "check:lib": "vue-tsc -p tsconfig.lib.json --noEmit", + "pack:lib": "npm run build:lib && npm pack ./packages/edentext-vue", "test": "vitest run", "test:watch": "vitest", "test:lo": "vitest run tests/lo-roundtrip.test.ts tests/lo-fuzz.test.ts tests/lo-render-consistency.test.ts tests/lo-password.test.ts", @@ -30,6 +36,7 @@ "devDependencies": { "@sveltejs/vite-plugin-svelte": "^7.1.2", "@tsconfig/svelte": "^5.0.8", + "@vitejs/plugin-vue": "^6.0.9", "@vitest/coverage-v8": "^4.1.10", "dictionary-de": "^3.0.0", "dictionary-en": "^4.0.0", @@ -43,7 +50,9 @@ "svelte-check": "^4.7.1", "typescript": "^6.0.3", "vite": "^8.0.14", - "vitest": "^4.1.9" + "vitest": "^4.1.9", + "vue": "^3.5.43", + "vue-tsc": "^3.3.11" }, "dependencies": { "@noble/ciphers": "^2.4.0", @@ -78,5 +87,8 @@ "jspdf": "^4.2.1", "odf-kit": "^0.13.5", "utif2": "^4.1.0" + }, + "allowScripts": { + "core-js@3.49.0": true } } diff --git a/packages/edentext-vue/README.md b/packages/edentext-vue/README.md new file mode 100644 index 00000000..4c597e24 --- /dev/null +++ b/packages/edentext-vue/README.md @@ -0,0 +1,29 @@ +# edentext-vue + +Vue 3 thin wrapper for the original EdenText browser editor. It mounts the +native EdenText UI and exposes only host commands such as new, open, save as, +UI language and document language. It does not recreate the toolbar and does +not expose TipTap, ProseMirror or Svelte APIs. + +```vue + + + +``` + +The package is browser-only, supports Vue 3, contains no React entry or React +dependency, and is licensed under AGPL-3.0-only. See the repository guide at +[`docs/edentext-vue.md`](../../docs/edentext-vue.md) for the complete API and +npm publishing workflow. diff --git a/packages/edentext-vue/package.json b/packages/edentext-vue/package.json new file mode 100644 index 00000000..aadb90e4 --- /dev/null +++ b/packages/edentext-vue/package.json @@ -0,0 +1,33 @@ +{ + "name": "edentext-vue", + "version": "0.1.0", + "description": "A browser-only Vue 3 rich-text document editor with ODT, DOCX and PDF export.", + "type": "module", + "license": "AGPL-3.0-only", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/vue/index.d.ts", + "files": [ + "dist", + "README.md", + "LICENSE", + "THIRD-PARTY-NOTICES.md" + ], + "scripts": { + "prepack": "node ../../scripts/prepare-edentext-vue-package.mjs", + "postpack": "node ../../scripts/prepare-edentext-vue-package.mjs --clean" + }, + "exports": { + ".": { + "types": "./dist/vue/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css", + "./assets/*": "./dist/assets/*", + "./package.json": "./package.json" + }, + "peerDependencies": { + "vue": "^3.4.0" + }, + "sideEffects": ["./dist/style.css"] +} diff --git a/scripts/prepare-edentext-vue-package.mjs b/scripts/prepare-edentext-vue-package.mjs new file mode 100644 index 00000000..021b41f1 --- /dev/null +++ b/scripts/prepare-edentext-vue-package.mjs @@ -0,0 +1,13 @@ +import { copyFile, rm } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const packageDir = resolve(root, 'packages/edentext-vue'); +const files = ['LICENSE', 'THIRD-PARTY-NOTICES.md']; + +if (process.argv.includes('--clean')) { + await Promise.all(files.map((name) => rm(resolve(packageDir, name), { force: true }))); +} else { + await Promise.all(files.map((name) => copyFile(resolve(root, name), resolve(packageDir, name)))); +} diff --git a/src/App.svelte b/src/App.svelte index eeab4228..fabf979d 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -76,7 +76,9 @@ import StyleManagerDialog from './lib/components/StyleManagerDialog.svelte'; import NoteOptionsDialog from './lib/components/NoteOptionsDialog.svelte'; import SaveFormatDialog from './lib/components/SaveFormatDialog.svelte'; - import { t, locale } from './lib/i18n/i18n.svelte'; + import { t, locale, setLocale } from './lib/i18n/i18n.svelte'; + import type { Locale } from './lib/i18n/config'; + import type { NativeEdenTextApi, NativeEdenTextAppProps } from './lib/native/types'; import { fnv1a } from './lib/utils/hash'; import { withShortcut } from './lib/i18n/shortcut'; import { DEFAULT_SHORTCUTS, matchesEvent, shortcutHint } from './lib/editor/shortcuts'; @@ -94,6 +96,23 @@ launchQueue?: { setConsumer: (c: (p: { files: FileSystemFileHandle[] }) => void) => void }; }; + // App.svelte remains the native EdenText application. These props only provide a + // narrow command bridge for framework wrappers; they do not replace any native + // toolbar, editor, pagination, or document state. + let { + initialUiLocale, + initialDocumentLanguage, + initialNewDocument = false, + assetBaseUrl, + onReady, + }: NativeEdenTextAppProps = $props(); + let nativeApiReady = false; + + function appAsset(name: string): string { + const base = assetBaseUrl?.replace(/\/$/, ''); + return base ? `${base}/${name}` : name; + } + let editor: Editor | null = $state(null); let tick: number = $state(0); let currentPage: number = $state(1); @@ -266,6 +285,10 @@ ({ main: documentLanguage, other: documentLanguageOther } = pickDocumentLanguage(documentLanguage, documentLanguageOther, code)); } + function setUiLocale(next: Locale): void { + setLocale(next); + } + // The document name (without .odt). Source of truth for the save filename; // set on open, editable in the header, blank → heading-derived fallback. let documentName: string = $state(loadDocName()); @@ -714,9 +737,9 @@ void clearEmbeddedFontStore(); } - function handleNew() { + function handleNew(skipConfirm = false) { if (!editor) return; - if (isDocNonEmpty() && !confirm(t().dialogs.confirmNew)) return; + if (!skipConfirm && isDocNonEmpty() && !confirm(t().dialogs.confirmNew)) return; loadContent('

'); // onUpdate fires → autosave documentEpoch++; resetHistory(); @@ -1015,6 +1038,43 @@ await applyImport(new Uint8Array(await file.arrayBuffer()), null, file.name); } + // Framework wrappers pass bytes into the same importer used by the native file + // picker. No parallel Vue document model is created here. + async function openDocument(source: File | Blob | ArrayBuffer | Uint8Array, filename?: string): Promise { + const bytes = source instanceof Uint8Array + ? source + : source instanceof ArrayBuffer + ? new Uint8Array(source) + : new Uint8Array(await source.arrayBuffer()); + const sourceName = filename + ?? (typeof File !== 'undefined' && source instanceof File ? source.name : undefined) + ?? 'document.odt'; + await applyImport(bytes, null, sourceName); + } + + // The Vue wrapper receives this object after the real native editor exists. The + // methods below call the same functions wired to EdenText's own ribbon buttons. + $effect(() => { + if (nativeApiReady || !editor) return; + nativeApiReady = true; + if (initialUiLocale) setUiLocale(initialUiLocale); + if (initialDocumentLanguage) setDocumentLanguage(initialDocumentLanguage); + if (initialNewDocument) handleNew(true); + + const api: NativeEdenTextApi = { + newDocument: () => handleNew(), + openFile: () => handleOpen(), + openDocument, + save: () => handleSave(), + saveAs: (format) => handleSaveAs(format), + exportDocument: (format) => format === 'pdf' ? handleExportPdf() : handleSaveAs(format), + setUiLocale, + setDocumentLanguage, + focus: () => editor?.commands.focus(), + }; + queueMicrotask(() => onReady?.(api)); + }); + // Both exporters take the same document-wide arguments, and every save path needs // one of them. The exporter module loads on first use. function exportLanguage() { @@ -1402,6 +1462,7 @@ onNewComment={addComment} {navigatorOpen} onToggleNavigator={() => (navigatorOpen = !navigatorOpen)} + {assetBaseUrl} /> {:else} @@ -1410,7 +1471,7 @@
@@ -1448,7 +1509,7 @@
-
- + diff --git a/src/lib/components/AboutDialog.svelte b/src/lib/components/AboutDialog.svelte index 4de2f98b..0af0c938 100644 --- a/src/lib/components/AboutDialog.svelte +++ b/src/lib/components/AboutDialog.svelte @@ -2,7 +2,12 @@ import { t } from '../i18n/i18n.svelte'; const REPO_URL = 'https://github.com/stffnb/edentext'; - let { open = $bindable(false) }: { open?: boolean } = $props(); + let { open = $bindable(false), assetBaseUrl = '' }: { open?: boolean; assetBaseUrl?: string } = $props(); + + function appAsset(name: string): string { + const base = assetBaseUrl.replace(/\/$/, ''); + return base ? `${base}/${name}` : name; + } let dialogEl: HTMLDialogElement | null = $state(null); @@ -40,9 +45,9 @@ - + - +

{t().about.tagline}

diff --git a/src/lib/components/ribbon/Ribbon.svelte b/src/lib/components/ribbon/Ribbon.svelte index 98e3f5f8..fd2705fd 100644 --- a/src/lib/components/ribbon/Ribbon.svelte +++ b/src/lib/components/ribbon/Ribbon.svelte @@ -86,6 +86,7 @@ onNew, onNewFromTemplate, onOpen, onSave, onSaveAs, onSaveTemplate, onExportPdf, onPrintPdf, onPrint, onAbout, onDocProperties, onProtect, onAutoCorrect, onAutoText, onNewComment, navigatorOpen = false, onToggleNavigator, recentFiles = [], onOpenRecent, onForgetRecent, + assetBaseUrl = '', }: { editor: Editor | null; tick: number; @@ -143,6 +144,7 @@ onDocProperties?: () => void; onProtect?: () => void; hasPassword?: boolean; + assetBaseUrl?: string; onAutoCorrect?: () => void; onAutoText?: () => void; onNewComment?: () => void; @@ -150,6 +152,11 @@ onToggleNavigator?: () => void; } = $props(); + function appAsset(name: string): string { + const base = assetBaseUrl.replace(/\/$/, ''); + return base ? `${base}/${name}` : name; + } + const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const; const CONTEXTUAL = ['tableDesign', 'tableLayout', 'pictureFormat', 'shapeFormat', 'headerFooter'] as const; type Tab = (typeof TABS)[number] | (typeof CONTEXTUAL)[number]; @@ -470,7 +477,7 @@
diff --git a/src/lib/editor/extensions/tableHeaderRow.ts b/src/lib/editor/extensions/tableHeaderRow.ts index 0c45a76c..dc31a55d 100644 --- a/src/lib/editor/extensions/tableHeaderRow.ts +++ b/src/lib/editor/extensions/tableHeaderRow.ts @@ -78,7 +78,8 @@ export function toggleHeaderStyle(axis: HeaderAxis) { return ({ state, tr, dispatch, commands }: CommandProps): boolean => { // A styled table: flip the Table Style Option the gallery's checkbox shows. if (styledTable(state)) { - return commands.setTableLook(LOOK_REGION[axis], !isHeaderStyled(state, axis)); + return (commands as unknown as { setTableLook: (region: string, enabled: boolean) => boolean }) + .setTableLook(LOOK_REGION[axis], !isHeaderStyled(state, axis)); } const info = headerCellPositions(state, axis); if (!info) return false; diff --git a/src/lib/native/bootstrap.ts b/src/lib/native/bootstrap.ts new file mode 100644 index 00000000..bea3e4fa --- /dev/null +++ b/src/lib/native/bootstrap.ts @@ -0,0 +1,35 @@ +import { locale } from '../i18n/i18n.svelte'; +import { applyTheme, loadTheme } from '../storage/theme'; + +let users = 0; +let stopThemeListener: (() => void) | null = null; + +/** + * Starts the small browser bootstrap that the original Svelte entry performs + * before mounting App.svelte. Keeping it here lets the Vue host mount the + * original application without duplicating its CSS/theme initialization. + */ +export function startNativeApp(): () => void { + if (users++ === 0) { + applyTheme(loadTheme()); + document.documentElement.lang = locale(); + + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const onThemeChange = () => { + if (loadTheme() === 'auto') applyTheme('auto'); + }; + media.addEventListener('change', onThemeChange); + stopThemeListener = () => media.removeEventListener('change', onThemeChange); + } + + let active = true; + return () => { + if (!active) return; + active = false; + users = Math.max(0, users - 1); + if (users === 0) { + stopThemeListener?.(); + stopThemeListener = null; + } + }; +} diff --git a/src/lib/native/types.ts b/src/lib/native/types.ts new file mode 100644 index 00000000..6f84c583 --- /dev/null +++ b/src/lib/native/types.ts @@ -0,0 +1,34 @@ +import type { DocumentFormat } from '../storage/documentName'; +import type { DocumentLanguage } from '../storage/documentLanguage'; +import type { Locale } from '../i18n/config'; + +/** + * Commands exposed by the native EdenText application surface. + * + * This deliberately contains commands, not TipTap, ProseMirror, Svelte, or a + * second document model. Consumers embed the original EdenText UI and ask it + * to perform the same actions that its own ribbon performs. + */ +export interface NativeEdenTextApi { + newDocument(): void; + openFile(): Promise; + openDocument(source: File | Blob | ArrayBuffer | Uint8Array, filename?: string): Promise; + save(): Promise; + saveAs(format: DocumentFormat): Promise; + exportDocument(format: DocumentFormat | 'pdf'): Promise; + setUiLocale(locale: Locale): void; + setDocumentLanguage(language: DocumentLanguage): void; + focus(): void; +} + +export interface NativeEdenTextAppProps { + /** Initial chrome language. The native UI picker remains the source of truth. */ + initialUiLocale?: Locale; + /** Initial spell-check/export language for the current document. */ + initialDocumentLanguage?: DocumentLanguage; + /** Start with the native application's blank-document command after mount. */ + initialNewDocument?: boolean; + /** Optional base URL for native logo/icon assets when the package is self-hosted. */ + assetBaseUrl?: string; + onReady?: (api: NativeEdenTextApi) => void; +} diff --git a/src/lib/vue/EdentextEditor.vue b/src/lib/vue/EdentextEditor.vue new file mode 100644 index 00000000..6d95e4f1 --- /dev/null +++ b/src/lib/vue/EdentextEditor.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/lib/vue/edentext-vue.css b/src/lib/vue/edentext-vue.css new file mode 100644 index 00000000..72b39bdd --- /dev/null +++ b/src/lib/vue/edentext-vue.css @@ -0,0 +1,6 @@ +/* Host-only styles. The editor UI itself is EdenText's native App.svelte CSS. */ +.edentext-vue-host { + width: 100%; + height: 100%; + min-height: 320px; +} diff --git a/src/lib/vue/index.ts b/src/lib/vue/index.ts new file mode 100644 index 00000000..5445fa55 --- /dev/null +++ b/src/lib/vue/index.ts @@ -0,0 +1,6 @@ +import EdentextEditor from './EdentextEditor.vue'; + +export { EdentextEditor }; +export type { EdentextEditorApi, EdentextEditorProps, UiLocale, DocumentFormat, DocumentLanguage, NativeEdenTextApi } from './types'; + +export default EdentextEditor; diff --git a/src/lib/vue/types.ts b/src/lib/vue/types.ts new file mode 100644 index 00000000..e5ee83fe --- /dev/null +++ b/src/lib/vue/types.ts @@ -0,0 +1,28 @@ +import type { DocumentFormat } from '../storage/documentName'; +import type { DocumentLanguage } from '../storage/documentLanguage'; +import type { Locale } from '../i18n/config'; +import type { NativeEdenTextApi } from '../native/types'; + +/** Props for the thin Vue host around the original EdenText application. */ +export interface EdentextEditorProps { + /** EdenText chrome language; the native language picker remains available. */ + locale?: Locale; + /** Default language of the document content and spell checker. */ + documentLanguage?: DocumentLanguage; + /** Run the native New command once the original editor has mounted. */ + initialNewDocument?: boolean; + /** Base URL containing EdenText.png and favicon.svg, if assets are hosted separately. */ + assetBaseUrl?: string; +} + +/** + * The public Vue API is a command surface over native EdenText. It intentionally + * does not expose TipTap, ProseMirror, Svelte, or a second content model. + */ +export interface EdentextEditorApi extends NativeEdenTextApi { + /** Unmounts the native EdenText application from the Vue host. */ + destroy(): void; +} + +export type UiLocale = Locale; +export type { DocumentFormat, DocumentLanguage, NativeEdenTextApi }; diff --git a/src/main.ts b/src/main.ts index c3aa55fc..478c7b7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,7 @@ import { mount } from 'svelte'; import App from './App.svelte'; import './styles/global.css'; -import { loadTheme, applyTheme } from './lib/storage/theme'; -import { locale } from './lib/i18n/i18n.svelte'; +import { startNativeApp } from './lib/native/bootstrap'; import { startTabPresence, pruneOldDocuments } from './lib/storage/docScope'; import { dragDialogs } from './lib/utils/dragWindow'; @@ -13,6 +12,10 @@ pruneOldDocuments(); dragDialogs(); +dragDialogs(); + +// Apply the same native theme/chrome bootstrap used by the Vue host. +startNativeApp(); // Apply saved theme before mount to prevent flash of wrong theme applyTheme(loadTheme()); diff --git a/tests/browser.mjs b/tests/browser.mjs index d68faa89..543eb49d 100644 --- a/tests/browser.mjs +++ b/tests/browser.mjs @@ -9,6 +9,8 @@ import * as playwright from 'playwright-core'; export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const MOD = process.platform === 'darwin' ? 'Meta' : 'Control'; +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'; export function checker() { const failures = []; @@ -26,8 +28,8 @@ export async function previewServer(port) { console.log('no dist/, building…'); execSync('npm run build', { cwd: ROOT, stdio: 'inherit' }); } - const proc = spawn('npm', ['run', 'preview', '--', '--port', String(port), '--strictPort'], - { cwd: ROOT, stdio: 'ignore', detached: true }); + const proc = spawn(npmCommand, ['run', 'preview', '--', '--port', String(port), '--strictPort'], + { cwd: ROOT, stdio: 'ignore', detached: true, shell: process.platform === 'win32' }); for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 500)); if (await fetch(`http://localhost:${port}/`).then(() => true).catch(() => false)) return proc; @@ -40,8 +42,8 @@ export async function previewServer(port) { export async function devServer(port) { const up = () => fetch(`http://localhost:${port}/`).then(() => true).catch(() => false); if (await up()) return null; - const proc = spawn('npx', ['vite', '--port', String(port), '--strictPort'], - { cwd: ROOT, stdio: 'ignore', detached: true }); + const proc = spawn(npxCommand, ['vite', '--port', String(port), '--strictPort'], + { cwd: ROOT, stdio: 'ignore', detached: true, shell: process.platform === 'win32' }); for (let i = 0; i < 120 && !(await up()); i++) await new Promise((r) => setTimeout(r, 500)); if (!(await up())) throw new Error('dev server did not start'); return proc; diff --git a/tsconfig.json b/tsconfig.json index 1b2a4f19..26f7d93c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,6 @@ "moduleDetection": "force", "strict": true }, - "include": ["src/**/*.ts", "src/**/*.svelte", "src/vite-env.d.ts"] + "include": ["src/**/*.ts", "src/**/*.svelte", "src/vite-env.d.ts"], + "exclude": ["src/lib/vue/**/*"] } diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 00000000..c4a9bf3e --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "packages/edentext-vue/dist", + "rootDir": "src/lib", + "allowJs": false, + "checkJs": false, + "types": ["vite/client", "svelte"] + }, + "include": [ + "src/lib/vue/**/*.ts", + "src/lib/vue/**/*.vue", + "src/lib/native/**/*.ts", + "src/vite-env.d.ts" + ], + "exclude": [] +} diff --git a/vite.lib.config.ts b/vite.lib.config.ts new file mode 100644 index 00000000..facdbe13 --- /dev/null +++ b/vite.lib.config.ts @@ -0,0 +1,57 @@ +import { fileURLToPath } from 'node:url'; +import { cp } from 'node:fs/promises'; +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import pkg from './package.json' with { type: 'json' }; + +const projectRoot = fileURLToPath(new URL('.', import.meta.url)); +const packageRoot = fileURLToPath(new URL('./packages/edentext-vue/', import.meta.url)); + +function copyPackageAssets() { + return { + name: 'copy-edentext-package-assets', + async closeBundle() { + const assets = [ + ['src/assets/fonts', 'dist/assets/fonts'], + ['public/dictionaries', 'dist/assets/dictionaries'], + ['public/thesaurus', 'dist/assets/thesaurus'], + ['public/font-licenses', 'dist/assets/font-licenses'], + ['public/EdenText.png', 'dist/assets/EdenText.png'], + ['public/favicon.svg', 'dist/assets/favicon.svg'], + ] as const; + for (const [source, target] of assets) { + await cp(`${projectRoot}/${source}`, `${packageRoot}/${target}`, { recursive: true, force: true }); + } + }, + }; +} + +export default defineConfig({ + plugins: [vue({ include: [/\.vue$/] }), svelte({ include: [/\.svelte$/] }), copyPackageAssets()], + publicDir: false, + build: { + outDir: 'packages/edentext-vue/dist', + emptyOutDir: true, + lib: { + entry: fileURLToPath(new URL('./src/lib/vue/index.ts', import.meta.url)), + name: 'EdentextVue', + formats: ['es'], + fileName: 'index', + cssFileName: 'style', + }, + rollupOptions: { + external: ['vue'], + output: { assetFileNames: (asset) => asset.name === 'style.css' ? 'style.css' : 'assets/[name]-[hash][extname]' }, + }, + }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, + resolve: { + alias: { + 'hunspell-asm': 'hunspell-asm/dist/cjs/index.js', + 'emscripten-wasm-loader': 'emscripten-wasm-loader/dist/cjs/index.js', + }, + }, +});