Skip to content
Closed
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
2 changes: 1 addition & 1 deletion .github/workflows/ci-examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ jobs:
strategy:
fail-fast: false
matrix:
surface: [toolbar]
surface: [toolbar, comments]
framework: [vanilla]
steps:
- name: Restore workspace
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ Build your own toolbar, comments sidebar, and review panel against the `superdoc
| Example | Docs |
|---------|------|
| [toolbar/vanilla](./editor/custom-ui/toolbar/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/overview) |
| [comments/vanilla](./editor/custom-ui/comments/vanilla) | [docs](https://docs.superdoc.dev/editor/custom-ui/comments) |

### Theming

Expand Down
25 changes: 25 additions & 0 deletions examples/editor/custom-ui/comments/vanilla/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Custom UI: vanilla comments

A custom SuperDoc comments sidebar in plain TypeScript. Single file, no framework, copy-paste into your own app.

## What this teaches

- `ui.selection.capture()` to freeze the editor selection at the moment the user clicks "Add comment", so the anchor survives the textarea taking focus.
- `ui.comments.createFromCapture(capture, { text })` to anchor the new comment against that frozen snapshot, not the live (now-empty) selection.
- `ui.comments.observe(snapshot => ...)` to render the sidebar list from a single subscription.
- Resolve, reopen, and reply per card via `ui.comments.resolve / .reopen / .reply`.
- `ui.createScope()` for lifecycle, plus `ui.destroy()` cascading on tear-down.

## Run

```bash
pnpm install
pnpm dev
```

The `predev` script builds the local `superdoc` workspace package so type imports resolve from `dist/`. From a published `npm` install this step is unnecessary.

## See also

- Docs: [Custom UI > Comments](https://docs.superdoc.dev/editor/custom-ui/comments)
- Sibling examples: [`toolbar/vanilla`](../../toolbar/vanilla), [`track-changes/vanilla`](../../track-changes/vanilla)
22 changes: 22 additions & 0 deletions examples/editor/custom-ui/comments/vanilla/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 Custom UI: vanilla comments</title>
</head>
<body>
<div class="app">
<section class="editor-area">
<button id="add-comment" class="add-btn" disabled>Add comment</button>
<div id="composer-mount"></div>
<div id="editor"></div>
</section>
<aside class="sidebar">
<h2>Comments</h2>
<ul id="comments" class="comments"></ul>
</aside>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
18 changes: 18 additions & 0 deletions examples/editor/custom-ui/comments/vanilla/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "@superdoc-examples/custom-ui-comments-vanilla",
"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.
154 changes: 154 additions & 0 deletions examples/editor/custom-ui/comments/vanilla/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* Custom comments sidebar (vanilla TypeScript), single file.
*
* The load-bearing pattern is `ui.selection.capture()`:
*
* The user selects text, clicks Add comment, the textarea takes
* focus, and the editor's live selection visually clears. A
* composer that read the live selection at submit time would see
* `null` and refuse the create. `capture()` returns a frozen
* snapshot at the moment the composer opens, so
* `comments.createFromCapture(capture, { text })` anchors the new
* comment against the original selection regardless of where focus
* moves afterwards.
*
* The other patterns:
*
* - `ui.comments.observe(snapshot => ...)` drives the sidebar list
* from a single subscription. No event-wrapped shape.
* - `ui.comments.resolve / .reopen / .reply` route through the
* same Document API that powers DOCX import / export, so changes
* made here round-trip through Word.
* - `ui.createScope()` collects every subscription so the whole
* surface tears down cleanly on `ui.destroy()`.
*/

import { SuperDoc } from 'superdoc';
import { createSuperDocUI, type CommentsSlice, type SelectionCapture } 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 Rivera', email: 'alex@example.com' },
modules: { comments: false }, // disable built-in comments UI; we render our own
});

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

// DOM handles the example writes into.
const addBtn = document.querySelector<HTMLButtonElement>('#add-comment')!;
const composerMount = document.querySelector<HTMLElement>('#composer-mount')!;
const list = document.querySelector<HTMLUListElement>('#comments')!;

// Add-comment button is enabled only when the editor has a real
// positional selection. `ui.selection.observe` fires once
// synchronously and again on every selection change.
scope.add(
ui.selection.observe((sel) => {
addBtn.disabled = sel.empty || sel.selectionTarget == null;
}),
);

// The composer mounts only when the user clicks Add comment, and
// captures the selection at that moment.
addBtn.addEventListener('click', () => openComposer());

function openComposer(): void {
// Capture the selection NOW. The textarea will steal focus next.
const capture = ui.selection.capture();
if (!capture) return;

composerMount.innerHTML = `
<div class="composer">
<div class="quote">${capture.quotedText ? `"${escape(capture.quotedText)}"` : '<em>No text selection</em>'}</div>
<textarea autofocus placeholder="Write a comment…"></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 postBtn = composerMount.querySelector<HTMLButtonElement>('button[data-action="post"]')!;
const cancelBtn = composerMount.querySelector<HTMLButtonElement>('button[data-action="cancel"]')!;

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

cancelBtn.addEventListener('click', closeComposer);
postBtn.addEventListener('click', () => post(capture, ta.value));
}

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

function post(capture: SelectionCapture, raw: string): void {
const text = raw.trim();
if (!text) return;
const receipt = ui.comments.createFromCapture(capture, { text });
if (!receipt.success) {
console.error('[comments] create failed', receipt);
return;
}
closeComposer();
}

// Render the sidebar from the comments slice. One subscription, the
// whole list re-renders when the snapshot changes. For a real product
// you'd diff DOM; this example optimises for clarity.
scope.add(
ui.comments.observe((snapshot) => renderComments(snapshot)),
);

function renderComments(snapshot: CommentsSlice): void {
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 comment.';
list.appendChild(empty);
return;
}
for (const c of snapshot.items) {
if (c.parentCommentId) continue; // replies render under their root, below
const li = document.createElement('li');
li.className = `card${c.status === 'resolved' ? ' resolved' : ''}`;
li.innerHTML = `
<div class="author">${escape(c.creatorName ?? c.creatorEmail ?? 'Unknown')}</div>
${c.anchoredText ? `<div class="quote">"${escape(c.anchoredText)}"</div>` : ''}
<div class="body">${escape(c.text ?? '')}</div>
<div class="actions">
${c.status === 'resolved'
? `<button data-action="reopen" class="primary">Reopen</button>`
: `<button data-action="resolve">Resolve</button><button data-action="reply">Reply</button>`}
</div>
`;
li.querySelector('[data-action="resolve"]')?.addEventListener('click', () => ui.comments.resolve(c.id));
li.querySelector('[data-action="reopen"]')?.addEventListener('click', () => ui.comments.reopen(c.id));
li.querySelector('[data-action="reply"]')?.addEventListener('click', () => {
const text = window.prompt('Reply:');
if (text?.trim()) ui.comments.reply(c.id, { text: text.trim() });
});
list.appendChild(li);
}
}

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

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

* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 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: 8px 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; }

.add-btn {
height: 30px; padding: 0 12px;
background: transparent; border: 1px solid var(--border); border-radius: 4px;
color: var(--text); margin-bottom: 8px;
}
.add-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.add-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 button { padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
.composer button.primary { border-color: var(--accent); background: var(--accent); color: #fff; }
.composer 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.resolved { color: var(--resolved); background: var(--bg-muted); }
.card .author { font-weight: 600; font-size: 12px; }
.card .body { font-size: 13px; line-height: 1.4; margin: 4px 0; }
.card .quote { border-left: 2px solid var(--accent); padding-left: 8px; font-style: italic; color: var(--text-muted); font-size: 12px; margin: 4px 0; }
.card .actions { display: flex; gap: 6px; margin-top: 8px; }
.card .actions button { padding: 4px 10px; font-size: 12px; border: 1px solid var(--border); border-radius: 4px; background: transparent; }
.card .actions button.primary { border-color: var(--accent); color: var(--accent); }
16 changes: 16 additions & 0 deletions examples/editor/custom-ui/comments/vanilla/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/comments/vanilla/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 @@ -129,6 +129,16 @@
"docs": "https://docs.superdoc.dev/editor/custom-ui/overview",
"ci": true
},
{
"id": "editor-custom-ui-comments-vanilla",
"title": "Custom comments sidebar (vanilla)",
"category": "Editor",
"surface": "Custom UI",
"sourceRepo": "superdoc-dev/superdoc",
"sourcePath": "examples/editor/custom-ui/comments/vanilla",
"docs": "https://docs.superdoc.dev/editor/custom-ui/comments",
"ci": true
},
{
"id": "editor-spell-check-typo-js",
"title": "Spell check with Typo.js",
Expand Down
38 changes: 35 additions & 3 deletions pnpm-lock.yaml

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

Loading