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 @@
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',
+ },
+ },
+});