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
7 changes: 4 additions & 3 deletions apps/docs/editor/custom-ui/content-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,20 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti
| Read one control | `ui.contentControls.get({ id })` |
| Position your UI | `ui.contentControls.getRect({ id })` |
| Scroll a control into view | `ui.contentControls.scrollIntoView({ id })` |
| Scroll to it and put the cursor in | `ui.contentControls.focus({ id })` |
| Re-anchor your UI when the page moves | `ui.viewport.observe(() => ...)` |
| Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` |
| Change content, tags, or locks | `editor.doc.contentControls.*` |

`active` is the innermost control. For nested controls (an inline field inside a block clause), `activePath` carries the full stack, innermost first, so you don't also need `observe()` just to read the nesting.

`scrollIntoView` resolves the control's position from the document, so it works even when the control is on a page that hasn't rendered yet (the page mounts, then scrolls). It scrolls only - it does not move the cursor into the control.
`scrollIntoView` resolves the control's position from the document, so it works even when the control is on a page that hasn't rendered yet (the page mounts, then scrolls). It scrolls only - it does not move the cursor into the control. `focus` does both: scrolls to the control and places the caret inside so the user can start typing. `focus` is selection, not editing - it does not bypass lock or document-mode rules, so a locked or read-only control can be focused for inspection but edits are still blocked.

`ui.viewport.observe` is the single signal for "your `getRect()` coordinates may be stale, re-query": it fires (coalesced, once per frame) on scroll, resize, zoom, and layout reflow, so an overlay anchored with `getRect` stays glued without hand-wiring those events yourself.

## Current limits
## How the model works

- No focus-by-id helper. Clicking a control in the document still drives selection.
You build your UI *over* the control, not inside it. SuperDoc owns how the control's content is painted in the document; you turn off its built-in chrome and draw your own (chips, badges, panels) anchored with `getRect`, react with the events, and change content through `editor.doc.contentControls.*`. Custom field types are expressed as a `tag` - for example `{ kind: 'smartField', key: 'party_name' }`, interpreted by your own UI - the underlying control stays a standard Word SDT so it round-trips to `.docx`.

## See also

Expand Down
141 changes: 141 additions & 0 deletions demos/__tests__/contract-templates-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { test, expect } from '@playwright/test';

/**
* SD-3312 acceptance: clicking a field's "Focus" button in the contract-templates
* sidebar places the editor caret INSIDE that control (the "scroll there and let
* me edit" step, vs "Locate" which only scrolls). Dogfoods ui.contentControls.focus.
*
* Runs only for the contract-templates demo (the shared suite runs once per DEMO).
*/

// Short viewport so the bottom clause reliably starts below the fold for the
// off-screen focus case (the field case works at any height).
test.use({ viewport: { width: 1100, height: 520 } });

test('clicking a field Focus places the caret inside that control', async ({ page }) => {
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');

await page.route('**/ingest.superdoc.dev/**', (r) =>
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
);
await page.goto('/');
await page.waitForFunction(
() => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0,
null,
{ timeout: 30_000 },
);

// Fields tab is the default; the Focus buttons live on field rows.
await page.waitForSelector('[data-focus-field]');
const key = await page.getAttribute('[data-focus-field]', 'data-focus-field');
expect(key).toBeTruthy();

// Resolve which structuredContent control (by id) the caret currently sits in.
const controlKeyAtSelection = () =>
page.evaluate(() => {
const ed = (window as any).__demo.superdoc.activeEditor;
const from = ed?.state?.selection?.from;
if (typeof from !== 'number') return null;
let hit: string | null = null;
ed.state.doc.descendants((node: any, pos: number) => {
if (
(node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') &&
from > pos &&
from < pos + node.nodeSize
) {
try {
hit = JSON.parse(node.attrs.tag ?? '{}').key ?? null;
} catch {
hit = null;
}
}
return true;
});
return hit;
});

// Caret should not already be in this field's control.
expect(await controlKeyAtSelection()).not.toBe(key);

await page.click(`[data-focus-field="${key}"]`);

// After focus, the caret lands inside a control whose tag carries this key.
await expect.poll(controlKeyAtSelection, { timeout: 5_000 }).toBe(key);
});

test('focusing an off-screen clause scrolls it in AND lands the caret inside it', async ({ page }) => {
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');

await page.route('**/ingest.superdoc.dev/**', (r) =>
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
);
await page.goto('/');
await page.waitForFunction(
() => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0,
null,
{ timeout: 30_000 },
);

// Clause Focus buttons live in the (initially hidden) clauses panel.
await page.click('.tab[data-tab="clauses"]');
await page.waitForSelector('[data-focus-clause]');

// Bottom-most block clause: its painted id + sectionId (= the button's data attr).
const target = await page.evaluate(() => {
const ui = (window as any).__demo.state.ui;
const blocks = ui.contentControls.getSnapshot().items.filter((i: any) => i.kind === 'block');
const last = blocks[blocks.length - 1];
let sectionId: string | null = null;
try {
sectionId = JSON.parse(last?.properties?.tag ?? '{}').sectionId ?? null;
} catch {
sectionId = null;
}
return { id: last?.id ?? null, sectionId };
});
expect(target.id).toBeTruthy();
expect(target.sectionId).toBeTruthy();

// Scroll to the top so the bottom clause starts off-screen.
await page.evaluate(() => {
let node: HTMLElement | null = document.querySelector('.presentation-editor__pages');
while (node && !(node.scrollHeight > node.clientHeight + 4)) node = node.parentElement;
if (node) node.scrollTop = 0;
else window.scrollTo(0, 0);
});

const state = () =>
page.evaluate((id) => {
// caret's containing control id
const ed = (window as any).__demo.superdoc.activeEditor;
const from = ed?.state?.selection?.from;
let caretIn: string | null = null;
if (typeof from === 'number') {
ed.state.doc.descendants((node: any, pos: number) => {
if (
(node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') &&
from > pos &&
from < pos + node.nodeSize
) {
caretIn = String(node.attrs.id);
}
return true;
});
}
// is the control's painted element in the viewport?
const el = document.querySelector<HTMLElement>(`[data-sdt-id="${id}"]`);
const r = el?.getBoundingClientRect();
const inViewport = r ? r.top >= 0 && r.top <= window.innerHeight : false;
return { caretIn, inViewport };
}, target.id);

// Before focus: caret is not in the bottom clause and it's off-screen.
const before = await state();
expect(before.caretIn).not.toBe(target.id);
expect(before.inViewport).toBe(false);

await page.click(`[data-focus-clause="${target.sectionId}"]`);

// After focus: the control is scrolled into view AND the caret is inside it.
await expect.poll(state, { timeout: 6_000 }).toEqual({ caretIn: target.id, inViewport: true });
});
28 changes: 27 additions & 1 deletion demos/contract-templates/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,21 @@ function locateByTag(tag: string): void {
void ui.contentControls.scrollIntoView({ id: first.target.nodeId, block: 'center' });
}

/**
* Focus the first control carrying `tag`: scroll to it AND put the caret
* inside (ui.contentControls.focus), so the user can start editing. The
* counterpart to locateByTag (scroll only).
*/
function focusByTag(tag: string): void {
const ui = state.ui;
const editor = state.editor;
if (!ui || !editor?.doc) return;
const { items } = editor.doc.contentControls.selectByTag({ tag });
const first = items[0];
if (!first) return;
void ui.contentControls.focus({ id: first.target.nodeId, block: 'center' });
}

function renderPanels(): void {
renderFieldsPanel();
renderClausesPanel();
Expand All @@ -393,14 +408,20 @@ function renderFieldsPanel(): void {
row.innerHTML = `
<div class="row-label">
<label class="row-label-text" for="${inputId}">${escapeHtml(field.label)}</label>
<button class="locate" type="button" data-locate-field="${escapeAttr(field.key)}" aria-label="Locate ${escapeAttr(field.label)} in the document" title="Scroll to this field">Locate</button>
<span class="row-actions">
<button class="locate" type="button" data-locate-field="${escapeAttr(field.key)}" aria-label="Locate ${escapeAttr(field.label)} in the document" title="Scroll to this field">Locate</button>
<button class="focus" type="button" data-focus-field="${escapeAttr(field.key)}" aria-label="Focus ${escapeAttr(field.label)} in the document" title="Scroll to this field and place the cursor in it">Focus</button>
</span>
</div>
<input id="${inputId}" data-field="${field.key}" value="${escapeAttr(state.values[field.key] ?? '')}" />
`;
fieldsPanelEl.appendChild(row);
row.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
locateByTag(fieldTag(field.key));
});
row.querySelector<HTMLButtonElement>('.focus')?.addEventListener('click', () => {
focusByTag(fieldTag(field.key));
});
const input = row.querySelector<HTMLInputElement>('input');
if (!input) continue;
// Reactive: each keystroke debounces ~250ms and fans the value to every
Expand Down Expand Up @@ -435,6 +456,7 @@ function renderClausesPanel(): void {
<div class="clause-actions">
<span class="clause-status">Update available</span>
<button class="locate" type="button" data-locate-clause="${escapeAttr(clause.id)}" aria-label="Locate ${escapeAttr(clause.label)} in the document" title="Scroll to this clause">Locate</button>
<button class="focus" type="button" data-focus-clause="${escapeAttr(clause.id)}" aria-label="Focus ${escapeAttr(clause.label)} in the document" title="Scroll to this clause and place the cursor in it">Focus</button>
</div>
</header>
<p class="clause-summary">${escapeHtml(upgrade.summary)}</p>
Expand Down Expand Up @@ -469,6 +491,7 @@ function renderClausesPanel(): void {
<div class="clause-actions">
<span class="clause-status muted">Current</span>
<button class="locate" type="button" data-locate-clause="${escapeAttr(clause.id)}" aria-label="Locate ${escapeAttr(clause.label)} in the document" title="Scroll to this clause">Locate</button>
<button class="focus" type="button" data-focus-clause="${escapeAttr(clause.id)}" aria-label="Focus ${escapeAttr(clause.label)} in the document" title="Scroll to this clause and place the cursor in it">Focus</button>
</div>
</header>
<p class="clause-meta">Document ${escapeHtml(inDoc)}</p>
Expand All @@ -478,6 +501,9 @@ function renderClausesPanel(): void {
card.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
locateByTag(clauseTag(clause.id, inDoc));
});
card.querySelector<HTMLButtonElement>('.focus')?.addEventListener('click', () => {
focusByTag(clauseTag(clause.id, inDoc));
});
clausesPanelEl.appendChild(card);
}
}
Expand Down
13 changes: 9 additions & 4 deletions demos/contract-templates/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,12 @@ input:focus {
}
.btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; }

/* "Locate" — scroll a field/clause control into view (ui.contentControls.scrollIntoView). */
/* "Locate" — scroll a control into view (ui.contentControls.scrollIntoView);
"Focus" — scroll AND place the caret inside it (ui.contentControls.focus). */
.clause-actions { display: flex; align-items: center; gap: 8px; }
.locate {
.row-actions { display: flex; align-items: center; gap: 6px; }
.locate,
.focus {
font: inherit;
font-size: var(--sd-font-size-100, 11px);
line-height: 1;
Expand All @@ -150,8 +153,10 @@ input:focus {
cursor: pointer;
white-space: nowrap;
}
.locate:hover { color: var(--demo-text); border-color: var(--demo-accent); }
.locate:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; }
.locate:hover,
.focus:hover { color: var(--demo-text); border-color: var(--demo-accent); }
.locate:focus-visible,
.focus:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; }

#fields-panel .btn { width: 100%; margin-top: 12px; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3838,33 +3838,46 @@ export class PresentationEditor extends EventEmitter {
entityId: string,
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
): Promise<boolean> {
const pos = this.#resolveContentControlCaretPos(entityId);
if (pos == null) return false;
return this.scrollToPositionAsync(pos, {
behavior: options.behavior ?? 'smooth',
block: options.block ?? 'center',
});
}

/**
* Resolve a caret position inside the content control with `entityId`, or
* `null` when no such control exists in the body document.
*
* Prefers the first *text* position inside the control: only text positions
* reliably map to a layout fragment; wrapper boundaries (block, paragraph,
* run) sit between fragments. A deep `descendants` walk handles inline
* (`run > text`) and block (`paragraph > run > text`) nesting uniformly
* (`descendants` yields each child's position relative to the node's
* content, so the absolute position is `found.pos + 1 + rel`). An empty
* control with no text falls back to the first inside position.
*
* The id is normalized to a string before comparing: the id a consumer
* passes comes from the list / painted `data-sdt-id` (always a string), but
* the PM attr can be numeric, so a strict `===` would miss it.
*/
#resolveContentControlCaretPos(entityId: string): number | null {
const editor = this.#editor;
if (!editor || typeof entityId !== 'string' || entityId.length === 0) return false;
if (!editor || typeof entityId !== 'string' || entityId.length === 0) return null;

let found: { pos: number; node: ReturnType<typeof editor.state.doc.nodeAt> } | null = null;
editor.state.doc.descendants((node, pos) => {
if (found) return false;
const name = node.type?.name;
// Normalize the node id to a string before comparing. The id a
// consumer passes comes from the list / painted `data-sdt-id` (always
// a string), but the PM attr can be numeric, so a strict `===` would
// miss it. Matches the painted-DOM (`getRect`) id convention.
if ((name === 'structuredContent' || name === 'structuredContentBlock') && String(node.attrs?.id) === entityId) {
found = { pos, node };
return false;
}
return true;
});
if (!found) return false;

// Resolve the first *text* position inside the control. Only text
// positions reliably map to a layout fragment; wrapper boundaries
// (block, paragraph, run) sit between fragments and make
// `scrollToPositionAsync` fail. A deep `descendants` walk handles
// inline (`run > text`) and block (`paragraph > run > text`) nesting
// uniformly. `descendants` yields each child's position relative to
// the node's content, so the absolute position is `found.pos + 1 + rel`.
// Scroll-only: this does NOT move the selection or focus.
if (!found) return null;

let contentPos = found.pos + 1;
let textFound = false;
found.node?.descendants((child, rel) => {
Expand All @@ -3876,11 +3889,65 @@ export class PresentationEditor extends EventEmitter {
}
return true;
});
return contentPos;
}

/**
* Focus a content control (SDT field/clause) by its id: place the caret
* inside it and scroll it into view — the "take me there and let me edit"
* counterpart to the scroll-only {@link scrollContentControlIntoView}.
*
* Selection, not mutation: locks (`sdtLocked` / `contentLocked` / …) and
* `viewing` mode do NOT block placing the caret — they still block the edits
* the user then attempts, via the normal editing rules. So a custom UI can
* focus a locked clause to let the user inspect it.
*
* Caret-inside (not a wrapper NodeSelection): both SDT node types are
* `atom: false`, so a `TextSelection` inside is the meaningful selection.
*
* v1 is body-only: searches the body editor, so a control in a
* header/footer/note story resolves to `not-found`.
*
* @returns `{ success: true }` once focused, or `{ success: false, reason }`
* for a real navigation problem: `not-ready` (no editor), `invalid-id`
* (empty id), `not-found` (unknown id / non-body), `not-reachable`
* (found but the page could not be scrolled into view).
*/
async focusContentControl(
entityId: string,
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
): Promise<{ success: true } | { success: false; reason: 'not-ready' | 'invalid-id' | 'not-found' | 'not-reachable' }> {
const editor = this.#editor;
if (!editor) return { success: false, reason: 'not-ready' };
if (typeof entityId !== 'string' || entityId.length === 0) return { success: false, reason: 'invalid-id' };

const pos = this.#resolveContentControlCaretPos(entityId);
if (pos == null) return { success: false, reason: 'not-found' };

// Without setTextSelection the editor can't place the caret, so focus
// can't honor its "caret placed" contract — fail before scrolling.
if (typeof editor.commands?.setTextSelection !== 'function') {
return { success: false, reason: 'not-ready' };
}

return this.scrollToPositionAsync(contentPos, {
// Scroll first and honor the result. A focus that can't bring the control
// into view must not report success (it would leave a caret on a page that
// never mounted) — matches #scrollToBlockCandidate. Model-aware: mounts a
// virtualized page first.
const scrolled = await this.scrollToPositionAsync(pos, {
behavior: options.behavior ?? 'smooth',
block: options.block ?? 'center',
});
if (!scrolled) return { success: false, reason: 'not-reachable' };

// Place the caret inside the control and honor the result — report success
// only if the selection was actually placed. setTextSelection clamps and
// focuses the (hidden) editor view with preventScroll, so keyboard input
// goes to the control without re-jumping the viewport.
if (!editor.commands.setTextSelection({ from: pos, to: pos })) {
return { success: false, reason: 'not-reachable' };
}
return { success: true };
}

/**
Expand Down
Loading
Loading