Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added examples/vue/public/sample.docx
Binary file not shown.
239 changes: 203 additions & 36 deletions examples/vue/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,59 +1,226 @@
<template>
<div class="app">
<header class="header">
<h1>DOCX Editor — Vue Example</h1>
<p class="subtitle">
This is a scaffold for the Vue.js integration of <code>@eigenpal/docx-core</code>.
The editor component is not yet implemented.
</p>
</header>
<main class="content">
<div class="placeholder">
<p>Vue editor component coming soon.</p>
<p>
See
<a href="https://github.com/eigenpal/docx-js-editor" target="_blank" rel="noopener">
the repository
</a>
for contribution guidelines.
</p>
<div class="header-left">
<h1 class="title">DOCX Editor — Vue</h1>
</div>
<div class="header-center">
<span v-if="fileName" class="file-name">{{ fileName }}</span>
</div>
<div class="header-right">
<label class="btn btn-primary">
<input
type="file"
accept=".docx"
@change="handleFileSelect"
class="file-input"
/>
Open DOCX
</label>
<button class="btn" @click="handleNew">New</button>
<button class="btn" @click="handleSave">Save</button>
<span v-if="status" class="status">{{ status }}</span>
</div>
</header>

<main class="main">
<DocxEditorVue
ref="editorRef"
:document-buffer="documentBuffer"
:document="currentDocument"
:show-toolbar="true"
@change="handleDocumentChange"
@error="handleError"
@ready="handleReady"
/>
</main>
</div>
</template>

<script setup lang="ts">
// Future: import { DocxEditor } from '@eigenpal/docx-editor-vue';
import { ref, onMounted } from 'vue';
import { DocxEditorVue } from '@eigenpal/docx-editor-vue';
import { createEmptyDocument } from '@eigenpal/docx-core';
import type { Document } from '@eigenpal/docx-core/types/document';

const editorRef = ref<InstanceType<typeof DocxEditorVue> | null>(null);
const documentBuffer = ref<ArrayBuffer | null>(null);
const currentDocument = ref<Document | null>(null);
const fileName = ref('sample.docx');
const status = ref('');

// Load sample document on mount
onMounted(async () => {
try {
const res = await fetch('/sample.docx');
const buffer = await res.arrayBuffer();
documentBuffer.value = buffer;
fileName.value = 'sample.docx';
} catch {
currentDocument.value = createEmptyDocument();
fileName.value = 'Untitled.docx';
}
});

function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;

status.value = 'Loading...';
file.arrayBuffer().then((buffer) => {
currentDocument.value = null;
documentBuffer.value = buffer;
fileName.value = file.name;
status.value = '';
}).catch(() => {
status.value = 'Error loading file';
});
}

function handleNew() {
documentBuffer.value = null;
currentDocument.value = createEmptyDocument();
fileName.value = 'Untitled.docx';
status.value = '';
}

async function handleSave() {
if (!editorRef.value) return;

try {
status.value = 'Saving...';
const blob = await editorRef.value.save();
if (blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName.value || 'document.docx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.value = 'Saved!';
setTimeout(() => { status.value = ''; }, 2000);
}
} catch {
status.value = 'Save failed';
}
}

function handleDocumentChange(_doc: Document) {
// no-op — could track dirty state here
}

function handleError(error: Error) {
console.error('Editor error:', error);
status.value = `Error: ${error.message}`;
}

function handleReady() {
console.log('Editor ready');
}
</script>

<style scoped>
<style>
.app {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: #f8fafc;
}

.header {
margin-bottom: 2rem;
display: flex;
align-items: center;
padding: 8px 16px;
gap: 12px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}

.header-left {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;

.title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin: 0;
}

.header-center {
flex: 1;
display: flex;
justify-content: center;
}
.subtitle {

.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}

.file-name {
font-size: 13px;
color: #64748b;
font-size: 0.875rem;
padding: 4px 10px;
background: #f1f5f9;
border-radius: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.content {
background: white;
border-radius: 8px;
padding: 3rem;
text-align: center;

.btn {
padding: 6px 12px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: #334155;
white-space: nowrap;
}
.placeholder {
color: #94a3b8;

.btn:hover {
background: #f1f5f9;
}
.placeholder a {
color: #3b82f6;

.btn-primary {
background: #0f172a;
color: #fff;
border-color: #0f172a;
cursor: pointer;
}

.btn-primary:hover {
background: #1e293b;
}

.file-input {
display: none;
}

.status {
font-size: 12px;
color: #64748b;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}

.main {
flex: 1;
display: flex;
overflow: hidden;
}
</style>
2 changes: 2 additions & 0 deletions openspec/changes/add-vue-package-harness/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-04
76 changes: 76 additions & 0 deletions openspec/changes/add-vue-package-harness/design.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading