Skip to content
Merged
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
30 changes: 29 additions & 1 deletion .github/workflows/ci-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,34 @@ jobs:
working-directory: examples/__tests__
run: EXAMPLE=editor/built-in-ui/${{ matrix.example }} npx playwright test

custom-ui:
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
example: [selection-capture]
steps:
- name: Restore workspace
uses: actions/cache/restore@v4
with:
path: |
.
~/.cache/ms-playwright
key: examples-workspace-${{ github.sha }}

- uses: pnpm/action-setup@v4

- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'

- name: Run smoke test
working-directory: examples/__tests__
run: EXAMPLE=editor/custom-ui/${{ matrix.example }} npx playwright test

ai:
needs: build
runs-on: ubuntu-latest
Expand Down Expand Up @@ -213,7 +241,7 @@ jobs:
validate:
name: CI Examples / validate
if: always()
needs: [getting-started, collaboration, built-in-ui, ai, advanced-headless-toolbar, advanced-extensions, headless]
needs: [getting-started, collaboration, built-in-ui, custom-ui, ai, advanced-headless-toolbar, advanced-extensions, headless]
runs-on: ubuntu-latest
steps:
- name: Check results
Expand Down
6 changes: 6 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ Patterns for the browser editor surface.
| [track-changes](./editor/built-in-ui/track-changes) | [docs](https://docs.superdoc.dev/editor/built-in-ui/track-changes) |
| [toolbar](./editor/built-in-ui/toolbar) | [docs](https://docs.superdoc.dev/editor/built-in-ui/toolbar) |

### Custom UI

| Example | Docs |
|---------|------|
| [selection-capture](./editor/custom-ui/selection-capture) | [docs](https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport) |

### Theming

| Example | Docs |
Expand Down
24 changes: 24 additions & 0 deletions examples/editor/custom-ui/selection-capture/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Custom UI: selection capture

The smallest example that proves why `ui.selection.capture()` exists. Single file, no framework.

## What this teaches

A comment composer cannot read the live selection at submit time. The textarea takes focus when the composer opens, the editor's live selection visually clears, and a `ui.selection.getSnapshot()` call at submit returns null. The fix is `ui.selection.capture()` at composer-open: a frozen snapshot of the selection that survives focus changes. Pass it to `ui.comments.createFromCapture(capture, { text })` at submit, and the new comment anchors against the original selection.

This example shows that flow and nothing else. No threading, no resolve / reopen / reply, no toolbar, no mode toggle. For the full Custom UI sidebar pattern, see [`demos/custom-ui`](../../../../../demos/custom-ui).

## Run

```bash
pnpm install
pnpm dev
```

Select text in the document. Click **Add comment on selection**. Type, then post. The new comment appears in the right-hand list anchored to the original selection, even though the selection visually cleared the moment the textarea took focus.

## See also

- [Custom UI > Comments](https://docs.superdoc.dev/editor/custom-ui/comments)
- [Custom UI > Selection and viewport](https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport)
- [Custom UI > Controller setup](https://docs.superdoc.dev/editor/custom-ui/controller-setup)
22 changes: 22 additions & 0 deletions examples/editor/custom-ui/selection-capture/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SuperDoc: selection.capture()</title>
</head>
<body>
<div class="app">
<section class="editor-area">
<button id="add-comment" class="btn" disabled>Add comment on selection</button>
<div id="composer-mount"></div>
<div id="editor"></div>
</section>
<aside class="sidebar">
<h2>Comments</h2>
<ul id="comments"></ul>
</aside>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/editor/custom-ui/selection-capture/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@superdoc-examples/custom-ui-selection-capture",
"private": true,
"type": "module",
"scripts": {
"predev": "pnpm --filter superdoc build",
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"superdoc": "workspace:*"
},
"devDependencies": {
"typescript": "catalog:",
"vite": "catalog:"
}
}
Binary file not shown.
123 changes: 123 additions & 0 deletions examples/editor/custom-ui/selection-capture/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* The smallest example that proves why `ui.selection.capture()` exists.
*
* The problem: a comment composer cannot read the live selection at
* submit time. The textarea takes focus the moment the composer
* opens; the editor's live selection visually clears. By the time
* the user types and clicks Post, `ui.selection.getSnapshot()`
* returns null.
*
* The fix: `ui.selection.capture()` returns a frozen snapshot of the
* selection at composer-open time. Hold it across the user's typing.
* Pass it to `ui.comments.createFromCapture(capture, { text })` at
* submit. The new comment anchors against the original selection
* regardless of where focus has moved.
*
* This example shows that flow and nothing else. No threading, no
* resolve / reopen / reply, no toolbar, no mode toggle. For the
* full Custom UI surface, see `demos/custom-ui` (React).
*/

import { SuperDoc } from 'superdoc';
import { createSuperDocUI } from 'superdoc/ui';
import 'superdoc/style.css';
import './style.css';

const superdoc = new SuperDoc({
selector: '#editor',
document: '/test_file.docx',
documentMode: 'editing',
user: { name: 'Alex', email: 'alex@example.com' },
modules: { comments: false }, // disable built-in comments UI; we render our own
});

const ui = createSuperDocUI({ superdoc });
const scope = ui.createScope();

const addBtn = document.querySelector<HTMLButtonElement>('#add-comment')!;
const composerMount = document.querySelector<HTMLElement>('#composer-mount')!;
const list = document.querySelector<HTMLUListElement>('#comments')!;

// Enable the Add button only when the editor has a positional
// selection. `selection.observe` fires once with the initial state
// and then on every selection change.
scope.add(
ui.selection.observe((sel) => {
addBtn.disabled = sel.empty || sel.selectionTarget == null;
}),
);

addBtn.addEventListener('click', () => {
// Capture NOW, before the textarea steals focus.
const capture = ui.selection.capture();
if (!capture) return;

composerMount.innerHTML = `
<div class="composer">
<div class="quote">${capture.quotedText ? `"${esc(capture.quotedText)}"` : '<em>No text selected</em>'}</div>
<textarea autofocus placeholder="Comment on the selection…"></textarea>
<div class="actions">
<button data-action="cancel">Cancel</button>
<button data-action="post" class="primary" disabled>Post</button>
</div>
</div>
`;

const ta = composerMount.querySelector<HTMLTextAreaElement>('textarea')!;
const post = composerMount.querySelector<HTMLButtonElement>('button[data-action="post"]')!;
const cancel = composerMount.querySelector<HTMLButtonElement>('button[data-action="cancel"]')!;

ta.addEventListener('input', () => {
post.disabled = ta.value.trim().length === 0;
});
ta.focus();

cancel.addEventListener('click', close);
post.addEventListener('click', () => {
const text = ta.value.trim();
if (!text) return;
// The captured snapshot still has the original anchor; the live
// selection has been gone since the textarea took focus.
const receipt = ui.comments.createFromCapture(capture, { text });
if (!receipt.success) console.error('[selection-capture] create failed', receipt);
close();
});
});

function close(): void {
composerMount.innerHTML = '';
}

// Render the comments list. One subscription, plain DOM.
scope.add(
ui.comments.observe((snapshot) => {
list.innerHTML = '';
if (snapshot.items.length === 0) {
const empty = document.createElement('li');
empty.className = 'empty';
empty.textContent = 'No comments yet. Select text and click Add.';
list.appendChild(empty);
return;
}
for (const c of snapshot.items) {
const li = document.createElement('li');
li.className = 'card';
li.innerHTML = `
${c.anchoredText ? `<div class="quote">"${esc(c.anchoredText)}"</div>` : ''}
<div class="body">${esc(c.text ?? '')}</div>
`;
list.appendChild(li);
}
}),
);

function esc(s: string): string {
return s.replace(/[&<>"]/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]!);
}

const teardown = () => {
ui.destroy();
superdoc.destroy();
};
window.addEventListener('beforeunload', teardown);
if (import.meta.hot) import.meta.hot.dispose(teardown);
37 changes: 37 additions & 0 deletions examples/editor/custom-ui/selection-capture/src/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
:root {
--bg: #fff;
--bg-muted: #f7f7f8;
--border: #e4e4e7;
--text: #18181b;
--text-muted: #71717a;
--accent: #2563eb;
--accent-soft: #eff6ff;
}

* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 14px; color: var(--text); background: var(--bg-muted); }
button, textarea { font: inherit; }
button { cursor: pointer; }

.app { display: grid; grid-template-columns: 1fr 320px; height: 100vh; }
.editor-area { overflow: auto; padding: 12px; }
.sidebar { background: var(--bg); border-left: 1px solid var(--border); overflow-y: auto; padding: 12px 16px; }
.sidebar h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 12px; }

.btn { height: 30px; padding: 0 12px; background: transparent; border: 1px solid var(--border); border-radius: 4px; color: var(--text); margin-bottom: 8px; }
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:disabled { color: var(--text-muted); cursor: not-allowed; opacity: 0.5; }

.composer { border: 1px solid var(--accent); background: var(--accent-soft); border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
.composer .quote { font-size: 12px; color: var(--text-muted); font-style: italic; border-left: 2px solid var(--accent); padding-left: 8px; margin-bottom: 8px; }
.composer textarea { width: 100%; min-height: 60px; resize: vertical; border: 1px solid var(--border); border-radius: 4px; padding: 8px; }
.composer .actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 8px; }
.composer .actions button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
.composer .actions button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
.composer .actions button.primary:disabled { opacity: 0.5; cursor: not-allowed; }

#comments { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
#comments .empty { color: var(--text-muted); font-size: 12px; }
.card { border: 1px solid var(--border); border-radius: 6px; padding: 10px 12px; background: var(--bg); }
.card .quote { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin-bottom: 4px; }
.card .body { font-size: 13px; line-height: 1.4; }
16 changes: 16 additions & 0 deletions examples/editor/custom-ui/selection-capture/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
3 changes: 3 additions & 0 deletions examples/editor/custom-ui/selection-capture/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { defineConfig } from 'vite';

export default defineConfig({});
10 changes: 10 additions & 0 deletions examples/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@
"docs": "https://docs.superdoc.dev/editor/built-in-ui/toolbar",
"ci": true
},
{
"id": "editor-custom-ui-selection-capture",
"title": "Custom UI: selection capture",
"category": "Editor",
"surface": "Custom UI",
"sourceRepo": "superdoc-dev/superdoc",
"sourcePath": "examples/editor/custom-ui/selection-capture",
"docs": "https://docs.superdoc.dev/editor/custom-ui/selection-and-viewport",
"ci": true
},
{
"id": "editor-theming",
"title": "Theming",
Expand Down
Loading
Loading