diff --git a/bun.lock b/bun.lock index 1d046530..3ab10f55 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^5.1.2", + "@vitejs/plugin-vue": "^6.0.4", "autoprefixer": "^10.4.17", "class-variance-authority": "^0.7.0", "eslint": "^9.39.2", @@ -32,6 +33,7 @@ "tsup": "^8.0.1", "typescript": "^5.3.3", "vite": "^7.3.1", + "vue": "^3.5.29", }, }, "packages/core": { @@ -41,7 +43,6 @@ "docx-editor-mcp": "./dist/mcp-cli.js", }, "dependencies": { - "clsx": "^2.1.0", "docxtemplater": "^3.50.0", "jszip": "^3.10.1", "pizzip": "^3.1.7", @@ -393,6 +394,8 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.2", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.53", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, ""], + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="], + "@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="], "@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="], @@ -1155,6 +1158,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.7.3", "", { "bin": "bin/semver.js" }, ""], + "@vitejs/plugin-vue/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, ""], diff --git a/examples/vue/public/sample.docx b/examples/vue/public/sample.docx new file mode 100644 index 00000000..a18bf186 Binary files /dev/null and b/examples/vue/public/sample.docx differ diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue index 847ea039..ac0c2da9 100644 --- a/examples/vue/src/App.vue +++ b/examples/vue/src/App.vue @@ -1,59 +1,226 @@ - diff --git a/openspec/changes/add-vue-package-harness/.openspec.yaml b/openspec/changes/add-vue-package-harness/.openspec.yaml new file mode 100644 index 00000000..5aae5cfa --- /dev/null +++ b/openspec/changes/add-vue-package-harness/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-04 diff --git a/openspec/changes/add-vue-package-harness/design.md b/openspec/changes/add-vue-package-harness/design.md new file mode 100644 index 00000000..bb5a39a2 --- /dev/null +++ b/openspec/changes/add-vue-package-harness/design.md @@ -0,0 +1,76 @@ +## Context + +The monorepo already has: + +- `packages/vue/` — a scaffold with `renderAsync` (throws "not implemented") and plugin types +- `examples/vue/` — a Vite app with `@vitejs/plugin-vue`, port 5174, alias config pointing at source — but shows a static "coming soon" page +- `packages/core/` — framework-agnostic core with `EditorCoordinator`, managers, ProseMirror extensions, layout-painter, and the full parsing/serialization pipeline +- `packages/react/` — the reference implementation with `DocxEditor`, `PagedEditor`, `HiddenProseMirror`, toolbar, and hooks + +The React editor's architecture: `DocxEditor` (orchestrator) → `PagedEditor` (visible pages + selection mapping) → `HiddenProseMirror` (off-screen PM instance). All rendering goes through `layout-painter` which is pure DOM manipulation (framework-agnostic). + +## Goals / Non-Goals + +**Goals:** + +- Add `bun run dev:vue` to root package.json, starting Vue example on port 5174 +- Create a minimal `DocxEditorVue` component that renders a DOCX with toolbar and basic editing +- Update `examples/vue/App.vue` to a working demo (open file, new document, save/download) +- Keep it minimal — a contributor-friendly harness, not feature parity with React + +**Non-Goals:** + +- Full feature parity with the React editor (find/replace, plugins, context menus, zoom control, ruler) +- Vue-specific implementations of all React hooks (useAutoSave, useTableSelection, etc.) +- Publishing `@eigenpal/docx-editor-vue` to npm +- Tests for the Vue package (can be added by contributors later) +- Mobile responsive layout in the Vue demo + +## Decisions + +### 1. Wrap layout-painter directly, not through React components + +The React editor's `PagedEditor` and `HiddenProseMirror` are React components that manage DOM directly. Since `layout-painter` is pure DOM manipulation and ProseMirror manages its own DOM, the Vue component can: + +- Create a hidden PM view in `onMounted` +- Use `layout-painter/renderPage.ts` to paint visible pages into a container div +- Handle click-to-position mapping same as React does + +**Alternative considered:** Port React components to Vue line-by-line. Rejected — too much work for a harness, and contributors should design Vue-idiomatic patterns. + +### 2. Single-file component approach + +One `DocxEditorVue.vue` SFC that: + +- Accepts props: `documentBuffer`, `document`, `showToolbar`, `readOnly`, `initialZoom` +- Emits: `change`, `error`, `fontsLoaded` +- Exposes ref methods: `save()`, `getDocument()`, `focus()` +- Uses `@eigenpal/docx-core` imports for parsing, PM setup, and layout painting + +This keeps the harness self-contained. Contributors can later extract composables. + +### 3. Reuse the React toolbar as-is (skip for minimal harness) + +The toolbar is a complex React component. For the minimal harness, we'll render the ProseMirror menubar plugin or skip toolbar entirely and let the hidden PM instance handle keyboard shortcuts. Contributors can build a Vue toolbar later. + +**Decision:** Include a basic Vue toolbar with essential formatting (bold, italic, underline, alignment) using direct ProseMirror commands. This gives contributors a working pattern to extend. + +### 4. Port structure mirrors React but stays minimal + +``` +packages/vue/src/ + components/ + DocxEditorVue.vue — Main editor component + BasicToolbar.vue — Minimal toolbar (B/I/U, alignment) + composables/ + useDocxEditor.ts — Core editor lifecycle (parse, PM, layout-painter) + index.ts — Updated exports + renderAsync.ts — Implement using DocxEditorVue + plugin-api/types.ts — Unchanged +``` + +## Risks / Trade-offs + +- **[Risk] Layout-painter integration may need React-specific assumptions removed** → Mitigation: layout-painter is pure DOM; the React coupling is only in PagedEditor.tsx which we're not using. We'll call the same functions directly. +- **[Risk] Contributor confusion about what's "done" vs "harness"** → Mitigation: Clear TODO comments and a README section listing what's implemented vs what needs work. +- **[Risk] ProseMirror CSS may clash with Vue scoped styles** → Mitigation: Use unscoped styles or import the existing editor.css from core. diff --git a/openspec/changes/add-vue-package-harness/proposal.md b/openspec/changes/add-vue-package-harness/proposal.md new file mode 100644 index 00000000..b924038e --- /dev/null +++ b/openspec/changes/add-vue-package-harness/proposal.md @@ -0,0 +1,28 @@ +## Why + +The Vue package (`packages/vue`) and its example (`examples/vue`) exist as scaffolds with placeholder content — `renderAsync` throws "not yet implemented" and the example shows a static "coming soon" page. There's no `dev:vue` script, so contributors can't easily spin up the Vue dev server. Adding a minimal working harness with a `bun run dev:vue` command lets Vue contributors start iterating immediately without figuring out the project structure. + +## What Changes + +- Add `dev:vue` script to root `package.json` to start the Vue example dev server on port 5174 +- Implement a minimal `DocxEditor` Vue component in `packages/vue` that wraps `@eigenpal/docx-core` to render a DOCX file with toolbar and basic editing +- Update `examples/vue/src/App.vue` from placeholder to a working demo (file open, new, save) mirroring the React Vite example's functionality +- Wire up the Vue example to use the new component + +## Capabilities + +### New Capabilities + +- `vue-editor-component`: Minimal Vue 3 component wrapping @eigenpal/docx-core that provides DOCX rendering and editing with toolbar support + +### Modified Capabilities + +_None — this is a new integration, no existing spec-level behavior changes._ + +## Impact + +- **packages/vue/**: New component files (DocxEditor.vue or composable), updated `index.ts` exports +- **examples/vue/**: Updated App.vue with working editor demo, possibly new dependencies +- **root package.json**: New `dev:vue` script +- **Dependencies**: `vue` (already a peer dep), may need `@eigenpal/docx-core` manager APIs +- **No breaking changes**: The existing placeholder exports remain; new component is additive diff --git a/openspec/changes/add-vue-package-harness/specs/vue-editor-component/spec.md b/openspec/changes/add-vue-package-harness/specs/vue-editor-component/spec.md new file mode 100644 index 00000000..d075c8f9 --- /dev/null +++ b/openspec/changes/add-vue-package-harness/specs/vue-editor-component/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: Dev server script for Vue + +The monorepo root `package.json` SHALL include a `dev:vue` script that starts the Vue example dev server on port 5174. + +#### Scenario: Start Vue dev server + +- **WHEN** a contributor runs `bun run dev:vue` from the monorepo root +- **THEN** Vite starts serving the Vue example at `http://localhost:5174` + +### Requirement: DocxEditorVue component renders DOCX + +The `packages/vue` package SHALL export a `DocxEditorVue` Vue 3 component that accepts a DOCX ArrayBuffer and renders it as editable pages using `@eigenpal/docx-core`. + +#### Scenario: Render a DOCX buffer + +- **WHEN** `DocxEditorVue` receives a `documentBuffer` prop containing a valid DOCX ArrayBuffer +- **THEN** the component parses the buffer, creates a ProseMirror editor, and paints visible pages using layout-painter + +#### Scenario: Render an empty document + +- **WHEN** `DocxEditorVue` receives a `document` prop with a Document model object +- **THEN** the component renders the document without needing a DOCX buffer + +### Requirement: Basic editing via ProseMirror + +The `DocxEditorVue` component SHALL support text editing through the hidden ProseMirror instance, handling keyboard input (typing, backspace, enter, undo/redo). + +#### Scenario: Type text into editor + +- **WHEN** the user clicks on the visible page area and types text +- **THEN** the hidden ProseMirror instance receives the input and layout-painter re-renders the visible pages with the new content + +### Requirement: Save document as DOCX + +The `DocxEditorVue` component SHALL expose a `save()` method via template ref that serializes the current editor state back to a DOCX Blob. + +#### Scenario: Save edited document + +- **WHEN** the host application calls `editorRef.save()` +- **THEN** the component returns a Promise resolving to a Blob containing the DOCX file + +### Requirement: Basic toolbar + +The `packages/vue` package SHALL include a `BasicToolbar.vue` component providing essential formatting controls: bold, italic, underline, and text alignment (left, center, right). + +#### Scenario: Apply bold formatting + +- **WHEN** the user selects text and clicks the Bold toolbar button +- **THEN** the selected text is toggled bold via the ProseMirror command + +### Requirement: Vue example app with file operations + +The `examples/vue/App.vue` SHALL provide a working demo with: open DOCX file, create new document, and save/download document. + +#### Scenario: Open a DOCX file + +- **WHEN** the user clicks "Open DOCX" and selects a .docx file +- **THEN** the file is loaded into the DocxEditorVue component and rendered + +#### Scenario: Create a new empty document + +- **WHEN** the user clicks "New" +- **THEN** the editor resets to an empty document + +#### Scenario: Save and download + +- **WHEN** the user clicks "Save" +- **THEN** the current document is serialized and downloaded as a .docx file + +### Requirement: Sample document on load + +The Vue example app SHALL load a sample DOCX file on initial page load, matching the React example behavior. + +#### Scenario: Initial load with sample + +- **WHEN** the Vue example page loads +- **THEN** the app fetches `/sample.docx` from the public directory and renders it in the editor diff --git a/openspec/changes/add-vue-package-harness/tasks.md b/openspec/changes/add-vue-package-harness/tasks.md new file mode 100644 index 00000000..a83b13d7 --- /dev/null +++ b/openspec/changes/add-vue-package-harness/tasks.md @@ -0,0 +1,36 @@ +## 1. Project Setup + +- [x] 1.1 Add `dev:vue` script to root `package.json`: `"dev:vue": "cd examples/vue && bun run dev"` +- [x] 1.2 Add `vue` and `@vitejs/plugin-vue` to Vue example devDependencies if missing, run `bun install` +- [x] 1.3 Copy `sample.docx` from `examples/vite/public/` to `examples/vue/public/` so the demo can load it on startup + +## 2. Core Composable + +- [x] 2.1 Create `packages/vue/src/composables/useDocxEditor.ts` — composable that manages the editor lifecycle: parse DOCX buffer → build ProseMirror state → create hidden PM view → paint pages via layout-painter → handle re-renders on state change +- [x] 2.2 Expose from composable: `editorView` ref, `save()` method, `destroy()` cleanup, `isReady` ref, `parseError` ref + +## 3. Vue Components + +- [x] 3.1 Create `packages/vue/src/components/DocxEditorVue.vue` — main editor SFC that uses `useDocxEditor`, renders hidden PM container + visible pages container, handles click-to-position mapping for selection +- [x] 3.2 Create `packages/vue/src/components/BasicToolbar.vue` — minimal toolbar with bold, italic, underline, and alignment buttons using ProseMirror commands +- [x] 3.3 Wire toolbar to editor view: pass `editorView` to BasicToolbar, dispatch commands on button click + +## 4. Package Exports + +- [x] 4.1 Update `packages/vue/src/index.ts` to export `DocxEditorVue` component and `useDocxEditor` composable +- [x] 4.2 Implement `renderAsync.ts` to mount `DocxEditorVue` into a container element using `createApp` + +## 5. Example App + +- [x] 5.1 Rewrite `examples/vue/src/App.vue` with working demo: header with Open/New/Save buttons, file name display, DocxEditorVue component +- [x] 5.2 Implement file open handler (file input → ArrayBuffer → pass to editor) +- [x] 5.3 Implement new document handler (createEmptyDocument from core → pass to editor) +- [x] 5.4 Implement save/download handler (call editor ref save → create download link) +- [x] 5.5 Load `sample.docx` on initial page load via fetch + +## 6. Verification + +- [x] 6.1 Run `bun run dev:vue`, verify page loads at localhost:5174 with sample document rendered +- [ ] 6.2 Verify typing, bold/italic/underline, and alignment work (requires manual browser testing) +- [ ] 6.3 Verify open file, new document, and save/download work (requires manual browser testing) +- [x] 6.4 Run `bun run typecheck` to confirm no type errors diff --git a/package.json b/package.json index ffc8f080..22d12ed3 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:nextjs": "cd examples/nextjs && npm run dev", "dev:remix": "cd examples/remix && npm run dev", "dev:astro": "cd examples/astro && npm run dev", + "dev:vue": "cd examples/vue && bun run dev", "dev:demo": "bash examples/dev-all.sh", "build": "bun run --filter '@eigenpal/docx-core' build && bun run --filter '@eigenpal/docx-js-editor' build", "build:demo": "vite build --config examples/vite/vite.config.ts", @@ -38,6 +39,7 @@ "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", "@vitejs/plugin-react": "^5.1.2", + "@vitejs/plugin-vue": "^6.0.4", "autoprefixer": "^10.4.17", "class-variance-authority": "^0.7.0", "eslint": "^9.39.2", @@ -54,7 +56,8 @@ "tailwindcss-animate": "^1.0.7", "tsup": "^8.0.1", "typescript": "^5.3.3", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vue": "^3.5.29" }, "keywords": [ "docx", diff --git a/packages/vue/src/components/BasicToolbar.vue b/packages/vue/src/components/BasicToolbar.vue new file mode 100644 index 00000000..aac4e29c --- /dev/null +++ b/packages/vue/src/components/BasicToolbar.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/packages/vue/src/components/DocxEditorVue.vue b/packages/vue/src/components/DocxEditorVue.vue new file mode 100644 index 00000000..a949d084 --- /dev/null +++ b/packages/vue/src/components/DocxEditorVue.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/packages/vue/src/composables/useDocxEditor.ts b/packages/vue/src/composables/useDocxEditor.ts new file mode 100644 index 00000000..bd1ee64c --- /dev/null +++ b/packages/vue/src/composables/useDocxEditor.ts @@ -0,0 +1,393 @@ +/** + * useDocxEditor — Vue composable for the DOCX editor lifecycle. + * + * Manages: DOCX parsing → ProseMirror state → layout pipeline → DOM painting. + * This is the Vue equivalent of PagedEditor + HiddenProseMirror from the React package. + */ + +import { ref, onBeforeUnmount, shallowRef, type Ref } from 'vue'; +import { EditorState, type Transaction, type Plugin } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; + +// Core imports — these all resolve through Vite aliases to packages/core/src/ +import { parseDocx } from '@eigenpal/docx-core/docx/parser'; +import { toProseDoc, createEmptyDoc } from '@eigenpal/docx-core/prosemirror/conversion'; +import { fromProseDoc } from '@eigenpal/docx-core/prosemirror/conversion/fromProseDoc'; +import { ExtensionManager } from '@eigenpal/docx-core/prosemirror/extensions/ExtensionManager'; +import { createStarterKit } from '@eigenpal/docx-core/prosemirror/extensions/StarterKit'; +import { toFlowBlocks } from '@eigenpal/docx-core/layout-bridge/toFlowBlocks'; +import { measureParagraph } from '@eigenpal/docx-core/layout-bridge/measuring'; +import { layoutDocument } from '@eigenpal/docx-core/layout-engine'; +import { renderPages } from '@eigenpal/docx-core/layout-painter/renderPage'; +import type { + FlowBlock, + Measure, + ParagraphBlock, + TableBlock, + TableMeasure, + ImageBlock, + PageMargins, +} from '@eigenpal/docx-core/layout-engine/types'; +import type { BlockLookup } from '@eigenpal/docx-core/layout-painter'; +import type { Document, SectionProperties } from '@eigenpal/docx-core/types/document'; + +// ProseMirror CSS — must be imported for the hidden editor to work +import 'prosemirror-view/style/prosemirror.css'; +import '@eigenpal/docx-core/prosemirror/editor.css'; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const DEFAULT_PAGE_WIDTH = 816; // 8.5in at 96dpi +const DEFAULT_PAGE_HEIGHT = 1056; // 11in at 96dpi +const DEFAULT_MARGINS: PageMargins = { top: 96, right: 96, bottom: 96, left: 96 }; +const DEFAULT_PAGE_GAP = 24; + +// ============================================================================ +// HELPERS +// ============================================================================ + +function twipsToPixels(twips: number): number { + return Math.round((twips / 1440) * 96); +} + +function getPageSize(sp: SectionProperties | null | undefined) { + return { + w: sp?.pageWidth ? twipsToPixels(sp.pageWidth) : DEFAULT_PAGE_WIDTH, + h: sp?.pageHeight ? twipsToPixels(sp.pageHeight) : DEFAULT_PAGE_HEIGHT, + }; +} + +function getMargins(sp: SectionProperties | null | undefined): PageMargins { + return { + top: sp?.marginTop ? twipsToPixels(sp.marginTop) : DEFAULT_MARGINS.top, + right: sp?.marginRight ? twipsToPixels(sp.marginRight) : DEFAULT_MARGINS.right, + bottom: sp?.marginBottom ? twipsToPixels(sp.marginBottom) : DEFAULT_MARGINS.bottom, + left: sp?.marginLeft ? twipsToPixels(sp.marginLeft) : DEFAULT_MARGINS.left, + }; +} + +/** + * Simplified block measurement (no floating zone support for the minimal harness). + * Contributors can port the full measureBlocks from PagedEditor later. + */ +function measureBlock(block: FlowBlock, contentWidth: number): Measure { + switch (block.kind) { + case 'paragraph': + return measureParagraph(block as ParagraphBlock, contentWidth); + + case 'table': { + const tb = block as TableBlock; + let columnWidths = tb.columnWidths ?? []; + if (columnWidths.length === 0 && tb.rows.length > 0) { + const colCount = tb.rows[0].cells.reduce((sum, cell) => sum + (cell.colSpan ?? 1), 0); + const equalWidth = contentWidth / Math.max(1, colCount); + columnWidths = Array(colCount).fill(equalWidth); + } + + const rows = tb.rows.map((row) => { + let colIdx = 0; + const cells = row.cells.map((cell) => { + const colSpan = cell.colSpan ?? 1; + let cellWidth = 0; + for (let c = 0; c < colSpan && colIdx + c < columnWidths.length; c++) { + cellWidth += columnWidths[colIdx + c] ?? 0; + } + if (cellWidth === 0) cellWidth = cell.width ?? 100; + colIdx += colSpan; + + const cellContentWidth = Math.max(1, cellWidth - 14); // ~7px padding each side + const blocks = cell.blocks.map((b) => measureBlock(b, cellContentWidth)); + const height = + blocks.reduce( + (h, m) => h + ('totalHeight' in m ? (m as { totalHeight: number }).totalHeight : 0), + 0 + ) + 2; // padding + + return { blocks, width: cellWidth, height, colSpan: cell.colSpan, rowSpan: cell.rowSpan }; + }); + + const maxHeight = Math.max(24, ...cells.map((c) => c.height)); + return { cells, height: maxHeight }; + }); + + const totalWidth = columnWidths.reduce((s, w) => s + w, 0) || contentWidth; + const totalHeight = rows.reduce((h, r) => h + r.height, 0); + return { kind: 'table', rows, columnWidths, totalWidth, totalHeight } as TableMeasure; + } + + case 'image': { + const ib = block as ImageBlock; + return { kind: 'image', width: ib.width ?? 100, height: ib.height ?? 100 }; + } + + case 'pageBreak': + return { kind: 'pageBreak' }; + + case 'columnBreak': + return { kind: 'columnBreak' }; + + case 'sectionBreak': + return { kind: 'sectionBreak' }; + + default: + return { kind: 'paragraph', lines: [], totalHeight: 0 }; + } +} + +function measureBlocks(blocks: FlowBlock[], contentWidth: number): Measure[] { + return blocks.map((b) => measureBlock(b, contentWidth)); +} + +// ============================================================================ +// COMPOSABLE +// ============================================================================ + +export interface UseDocxEditorOptions { + /** Container element for the hidden ProseMirror editor */ + hiddenContainer: Ref; + /** Container element for the visible pages */ + pagesContainer: Ref; + /** Whether the editor is read-only */ + readOnly?: boolean; + /** Page gap in pixels */ + pageGap?: number; + /** Callback on document change */ + onChange?: (doc: Document) => void; + /** Callback on error */ + onError?: (error: Error) => void; +} + +export function useDocxEditor(options: UseDocxEditorOptions) { + const { + hiddenContainer, + pagesContainer, + readOnly = false, + pageGap = DEFAULT_PAGE_GAP, + onChange, + onError, + } = options; + + // State + const document = shallowRef(null); + const editorView = shallowRef(null); + const isReady = ref(false); + const parseError = ref(null); + + // Extension manager (created once) + let extensionManager: ExtensionManager | null = null; + + function getExtensionManager(): ExtensionManager { + if (!extensionManager) { + extensionManager = new ExtensionManager(createStarterKit()); + extensionManager.buildSchema(); + extensionManager.initializeRuntime(); + } + return extensionManager; + } + + // ======================================================================== + // Layout pipeline + // ======================================================================== + + function runLayoutPipeline(state: EditorState) { + const container = pagesContainer.value; + if (!container || !document.value) return; + + const sp = document.value.package?.document?.finalSectionProperties ?? null; + const pageSize = getPageSize(sp); + const margins = getMargins(sp); + const contentWidth = pageSize.w - margins.left - margins.right; + const pageContentHeight = pageSize.h - margins.top - margins.bottom; + const theme = document.value.package?.theme ?? null; + + try { + // Step 1: PM doc → flow blocks + const blocks = toFlowBlocks(state.doc, { theme, pageContentHeight }); + + // Step 2: Measure blocks + const measures = measureBlocks(blocks, contentWidth); + + // Step 3: Layout + const newLayout = layoutDocument(blocks, measures, { pageSize, margins }); + + // Step 4: Build block lookup and paint + const blockLookup: BlockLookup = new Map(); + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i]; + const measure = measures[i]; + if (block && measure) { + blockLookup.set(String(block.id), { block, measure }); + } + } + + renderPages(newLayout.pages, container, { + pageGap, + showShadow: true, + pageBackground: '#fff', + blockLookup, + theme, + } as Parameters[2]); + } catch (err) { + console.error('[useDocxEditor] Layout pipeline error:', err); + onError?.(err instanceof Error ? err : new Error(String(err))); + } + } + + // ======================================================================== + // ProseMirror setup + // ======================================================================== + + function createEditorView() { + const host = hiddenContainer.value; + if (!host) return; + + const mgr = getExtensionManager(); + const doc = document.value + ? toProseDoc(document.value, { + styles: document.value.package?.styles ?? undefined, + }) + : createEmptyDoc(); + + const plugins: Plugin[] = [...(mgr.getPlugins() ?? [])]; + + const state = EditorState.create({ + doc, + schema: mgr.getSchema(), + plugins, + }); + + const view = new EditorView(host, { + state, + editable: () => !readOnly, + dispatchTransaction(transaction: Transaction) { + if (!view) return; + const newState = view.state.apply(transaction); + view.updateState(newState); + + // Re-layout on doc changes + if (transaction.docChanged) { + runLayoutPipeline(newState); + // Notify parent about document change + if (document.value) { + const updatedDoc = fromProseDoc(newState.doc, document.value); + onChange?.(updatedDoc); + } + } + }, + }); + + editorView.value = view; + isReady.value = true; + + // Initial layout + runLayoutPipeline(state); + } + + function destroyEditorView() { + if (editorView.value) { + editorView.value.destroy(); + editorView.value = null; + } + isReady.value = false; + } + + // ======================================================================== + // Document loading + // ======================================================================== + + async function loadBuffer(buffer: ArrayBuffer | Uint8Array | Blob | File) { + parseError.value = null; + isReady.value = false; + + try { + let arrayBuf: ArrayBuffer; + if (buffer instanceof Blob || buffer instanceof File) { + arrayBuf = await buffer.arrayBuffer(); + } else if (buffer instanceof Uint8Array) { + arrayBuf = buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ) as ArrayBuffer; + } else { + arrayBuf = buffer; + } + + const doc = await parseDocx(arrayBuf); + document.value = doc; + + // Recreate PM view with new document + destroyEditorView(); + createEditorView(); + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + parseError.value = error.message; + onError?.(error); + } + } + + function loadDocument(doc: Document) { + parseError.value = null; + document.value = doc; + destroyEditorView(); + createEditorView(); + } + + // ======================================================================== + // Public API + // ======================================================================== + + async function save(): Promise { + if (!editorView.value || !document.value) return null; + + const { repackDocx, createDocx } = await import('@eigenpal/docx-core/docx/rezip'); + + const updatedDoc = fromProseDoc(editorView.value.state.doc, document.value); + let buffer: ArrayBuffer; + if (updatedDoc.originalBuffer) { + buffer = await repackDocx(updatedDoc); + } else { + buffer = await createDocx(updatedDoc); + } + return new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + }); + } + + function focus() { + editorView.value?.focus(); + } + + function destroy() { + destroyEditorView(); + document.value = null; + } + + function getDocument(): Document | null { + return document.value; + } + + // ======================================================================== + // Lifecycle + // ======================================================================== + + onBeforeUnmount(() => { + destroy(); + }); + + return { + // State + editorView, + isReady, + parseError, + + // Actions + loadBuffer, + loadDocument, + save, + focus, + destroy, + getDocument, + }; +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 65bb80bc..d03b046d 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -4,7 +4,15 @@ // This package provides Vue 3 components wrapping @eigenpal/docx-core. // Contributions welcome! See the repository README for guidelines. -// renderAsync stub +// Components +export { default as DocxEditorVue } from './components/DocxEditorVue.vue'; +export { default as BasicToolbar } from './components/BasicToolbar.vue'; + +// Composables +export { useDocxEditor } from './composables/useDocxEditor'; +export type { UseDocxEditorOptions } from './composables/useDocxEditor'; + +// renderAsync export { renderAsync } from './renderAsync'; export type { VueRenderAsyncOptions } from './renderAsync'; diff --git a/packages/vue/src/renderAsync.ts b/packages/vue/src/renderAsync.ts index 02a0376e..92ec9417 100644 --- a/packages/vue/src/renderAsync.ts +++ b/packages/vue/src/renderAsync.ts @@ -1,13 +1,12 @@ /** - * Vue renderAsync — scaffold for community implementation. - * - * This is a placeholder that defines the expected API. - * A Vue contributor can implement this using `createApp().mount()`. + * Vue renderAsync — mounts a DocxEditorVue into a container element. */ +import { createApp, h, type App } from 'vue'; +import DocxEditorVue from './components/DocxEditorVue.vue'; import type { EditorHandle } from '@eigenpal/docx-core'; -/** Options for the Vue renderAsync (to be defined by implementor). */ +/** Options for the Vue renderAsync. */ export interface VueRenderAsyncOptions { readOnly?: boolean; showToolbar?: boolean; @@ -21,13 +20,59 @@ export interface VueRenderAsyncOptions { * @param options - Editor configuration * @returns A handle implementing the framework-agnostic EditorHandle interface */ -export function renderAsync( - _input: ArrayBuffer | Uint8Array | Blob | File, - _container: HTMLElement, - _options: VueRenderAsyncOptions = {} +export async function renderAsync( + input: ArrayBuffer | Uint8Array | Blob | File, + container: HTMLElement, + options: VueRenderAsyncOptions = {} ): Promise { - throw new Error( - '@eigenpal/docx-editor-vue renderAsync is not yet implemented. ' + - 'Community contributions welcome!' - ); + // Convert to ArrayBuffer upfront — loadBuffer also handles this, + // but we need a stable value for the prop. + let buffer: ArrayBuffer; + if (input instanceof Blob || input instanceof File) { + buffer = await input.arrayBuffer(); + } else if (input instanceof Uint8Array) { + buffer = input.buffer.slice( + input.byteOffset, + input.byteOffset + input.byteLength + ) as ArrayBuffer; + } else { + buffer = input; + } + + let editorRef: any = null; + + const app: App = createApp({ + setup() { + return () => + h(DocxEditorVue, { + documentBuffer: buffer, + showToolbar: options.showToolbar ?? true, + readOnly: options.readOnly ?? false, + ref: (el: any) => { + editorRef = el; + }, + }); + }, + }); + + app.mount(container); + + // Wait a tick for mount to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + return { + save: async () => { + return editorRef?.save() ?? null; + }, + getDocument: () => { + return editorRef?.getDocument() ?? null; + }, + focus: () => { + editorRef?.focus(); + }, + destroy: () => { + editorRef?.destroy(); + app.unmount(); + }, + }; } diff --git a/packages/vue/src/shims-vue.d.ts b/packages/vue/src/shims-vue.d.ts new file mode 100644 index 00000000..e16c3dc2 --- /dev/null +++ b/packages/vue/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent<{}, {}, any>; + export default component; +}