Skip to content

Commit a6ceac5

Browse files
authored
Merge pull request #3571 from superdoc-dev/caio/sd-3312-focus-activate
feat(ui): focus a content control to place the caret inside it (SD-3312)
2 parents 3bbdbe5 + 998561a commit a6ceac5

8 files changed

Lines changed: 363 additions & 24 deletions

File tree

apps/docs/editor/custom-ui/content-controls.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,20 @@ The event tells you *what* is active; `getRect` tells you *where* to draw. `acti
4040
| Read one control | `ui.contentControls.get({ id })` |
4141
| Position your UI | `ui.contentControls.getRect({ id })` |
4242
| Scroll a control into view | `ui.contentControls.scrollIntoView({ id })` |
43+
| Scroll to it and put the cursor in | `ui.contentControls.focus({ id })` |
4344
| Re-anchor your UI when the page moves | `ui.viewport.observe(() => ...)` |
4445
| Hover and right-click hit-testing | `ui.viewport.entityAt()` / `contextAt()` |
4546
| Change content, tags, or locks | `editor.doc.contentControls.*` |
4647

4748
`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.
4849

49-
`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.
50+
`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.
5051

5152
`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.
5253

53-
## Current limits
54+
## How the model works
5455

55-
- No focus-by-id helper. Clicking a control in the document still drives selection.
56+
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`.
5657

5758
## See also
5859

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* SD-3312 acceptance: clicking a field's "Focus" button in the contract-templates
5+
* sidebar places the editor caret INSIDE that control (the "scroll there and let
6+
* me edit" step, vs "Locate" which only scrolls). Dogfoods ui.contentControls.focus.
7+
*
8+
* Runs only for the contract-templates demo (the shared suite runs once per DEMO).
9+
*/
10+
11+
// Short viewport so the bottom clause reliably starts below the fold for the
12+
// off-screen focus case (the field case works at any height).
13+
test.use({ viewport: { width: 1100, height: 520 } });
14+
15+
test('clicking a field Focus places the caret inside that control', async ({ page }) => {
16+
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
17+
18+
await page.route('**/ingest.superdoc.dev/**', (r) =>
19+
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
20+
);
21+
await page.goto('/');
22+
await page.waitForFunction(
23+
() => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0,
24+
null,
25+
{ timeout: 30_000 },
26+
);
27+
28+
// Fields tab is the default; the Focus buttons live on field rows.
29+
await page.waitForSelector('[data-focus-field]');
30+
const key = await page.getAttribute('[data-focus-field]', 'data-focus-field');
31+
expect(key).toBeTruthy();
32+
33+
// Resolve which structuredContent control (by id) the caret currently sits in.
34+
const controlKeyAtSelection = () =>
35+
page.evaluate(() => {
36+
const ed = (window as any).__demo.superdoc.activeEditor;
37+
const from = ed?.state?.selection?.from;
38+
if (typeof from !== 'number') return null;
39+
let hit: string | null = null;
40+
ed.state.doc.descendants((node: any, pos: number) => {
41+
if (
42+
(node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') &&
43+
from > pos &&
44+
from < pos + node.nodeSize
45+
) {
46+
try {
47+
hit = JSON.parse(node.attrs.tag ?? '{}').key ?? null;
48+
} catch {
49+
hit = null;
50+
}
51+
}
52+
return true;
53+
});
54+
return hit;
55+
});
56+
57+
// Caret should not already be in this field's control.
58+
expect(await controlKeyAtSelection()).not.toBe(key);
59+
60+
await page.click(`[data-focus-field="${key}"]`);
61+
62+
// After focus, the caret lands inside a control whose tag carries this key.
63+
await expect.poll(controlKeyAtSelection, { timeout: 5_000 }).toBe(key);
64+
});
65+
66+
test('focusing an off-screen clause scrolls it in AND lands the caret inside it', async ({ page }) => {
67+
test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only');
68+
69+
await page.route('**/ingest.superdoc.dev/**', (r) =>
70+
r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }),
71+
);
72+
await page.goto('/');
73+
await page.waitForFunction(
74+
() => (window as any).__demo?.state?.ui?.contentControls?.getSnapshot()?.items?.length > 0,
75+
null,
76+
{ timeout: 30_000 },
77+
);
78+
79+
// Clause Focus buttons live in the (initially hidden) clauses panel.
80+
await page.click('.tab[data-tab="clauses"]');
81+
await page.waitForSelector('[data-focus-clause]');
82+
83+
// Bottom-most block clause: its painted id + sectionId (= the button's data attr).
84+
const target = await page.evaluate(() => {
85+
const ui = (window as any).__demo.state.ui;
86+
const blocks = ui.contentControls.getSnapshot().items.filter((i: any) => i.kind === 'block');
87+
const last = blocks[blocks.length - 1];
88+
let sectionId: string | null = null;
89+
try {
90+
sectionId = JSON.parse(last?.properties?.tag ?? '{}').sectionId ?? null;
91+
} catch {
92+
sectionId = null;
93+
}
94+
return { id: last?.id ?? null, sectionId };
95+
});
96+
expect(target.id).toBeTruthy();
97+
expect(target.sectionId).toBeTruthy();
98+
99+
// Scroll to the top so the bottom clause starts off-screen.
100+
await page.evaluate(() => {
101+
let node: HTMLElement | null = document.querySelector('.presentation-editor__pages');
102+
while (node && !(node.scrollHeight > node.clientHeight + 4)) node = node.parentElement;
103+
if (node) node.scrollTop = 0;
104+
else window.scrollTo(0, 0);
105+
});
106+
107+
const state = () =>
108+
page.evaluate((id) => {
109+
// caret's containing control id
110+
const ed = (window as any).__demo.superdoc.activeEditor;
111+
const from = ed?.state?.selection?.from;
112+
let caretIn: string | null = null;
113+
if (typeof from === 'number') {
114+
ed.state.doc.descendants((node: any, pos: number) => {
115+
if (
116+
(node.type.name === 'structuredContent' || node.type.name === 'structuredContentBlock') &&
117+
from > pos &&
118+
from < pos + node.nodeSize
119+
) {
120+
caretIn = String(node.attrs.id);
121+
}
122+
return true;
123+
});
124+
}
125+
// is the control's painted element in the viewport?
126+
const el = document.querySelector<HTMLElement>(`[data-sdt-id="${id}"]`);
127+
const r = el?.getBoundingClientRect();
128+
const inViewport = r ? r.top >= 0 && r.top <= window.innerHeight : false;
129+
return { caretIn, inViewport };
130+
}, target.id);
131+
132+
// Before focus: caret is not in the bottom clause and it's off-screen.
133+
const before = await state();
134+
expect(before.caretIn).not.toBe(target.id);
135+
expect(before.inViewport).toBe(false);
136+
137+
await page.click(`[data-focus-clause="${target.sectionId}"]`);
138+
139+
// After focus: the control is scrolled into view AND the caret is inside it.
140+
await expect.poll(state, { timeout: 6_000 }).toEqual({ caretIn: target.id, inViewport: true });
141+
});

demos/contract-templates/src/main.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,21 @@ function locateByTag(tag: string): void {
376376
void ui.contentControls.scrollIntoView({ id: first.target.nodeId, block: 'center' });
377377
}
378378

379+
/**
380+
* Focus the first control carrying `tag`: scroll to it AND put the caret
381+
* inside (ui.contentControls.focus), so the user can start editing. The
382+
* counterpart to locateByTag (scroll only).
383+
*/
384+
function focusByTag(tag: string): void {
385+
const ui = state.ui;
386+
const editor = state.editor;
387+
if (!ui || !editor?.doc) return;
388+
const { items } = editor.doc.contentControls.selectByTag({ tag });
389+
const first = items[0];
390+
if (!first) return;
391+
void ui.contentControls.focus({ id: first.target.nodeId, block: 'center' });
392+
}
393+
379394
function renderPanels(): void {
380395
renderFieldsPanel();
381396
renderClausesPanel();
@@ -393,14 +408,20 @@ function renderFieldsPanel(): void {
393408
row.innerHTML = `
394409
<div class="row-label">
395410
<label class="row-label-text" for="${inputId}">${escapeHtml(field.label)}</label>
396-
<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>
411+
<span class="row-actions">
412+
<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>
413+
<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>
414+
</span>
397415
</div>
398416
<input id="${inputId}" data-field="${field.key}" value="${escapeAttr(state.values[field.key] ?? '')}" />
399417
`;
400418
fieldsPanelEl.appendChild(row);
401419
row.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
402420
locateByTag(fieldTag(field.key));
403421
});
422+
row.querySelector<HTMLButtonElement>('.focus')?.addEventListener('click', () => {
423+
focusByTag(fieldTag(field.key));
424+
});
404425
const input = row.querySelector<HTMLInputElement>('input');
405426
if (!input) continue;
406427
// Reactive: each keystroke debounces ~250ms and fans the value to every
@@ -435,6 +456,7 @@ function renderClausesPanel(): void {
435456
<div class="clause-actions">
436457
<span class="clause-status">Update available</span>
437458
<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>
459+
<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>
438460
</div>
439461
</header>
440462
<p class="clause-summary">${escapeHtml(upgrade.summary)}</p>
@@ -469,6 +491,7 @@ function renderClausesPanel(): void {
469491
<div class="clause-actions">
470492
<span class="clause-status muted">Current</span>
471493
<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>
494+
<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>
472495
</div>
473496
</header>
474497
<p class="clause-meta">Document ${escapeHtml(inDoc)}</p>
@@ -478,6 +501,9 @@ function renderClausesPanel(): void {
478501
card.querySelector<HTMLButtonElement>('.locate')?.addEventListener('click', () => {
479502
locateByTag(clauseTag(clause.id, inDoc));
480503
});
504+
card.querySelector<HTMLButtonElement>('.focus')?.addEventListener('click', () => {
505+
focusByTag(clauseTag(clause.id, inDoc));
506+
});
481507
clausesPanelEl.appendChild(card);
482508
}
483509
}

demos/contract-templates/src/style.css

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,9 +136,12 @@ input:focus {
136136
}
137137
.btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; }
138138

139-
/* "Locate" — scroll a field/clause control into view (ui.contentControls.scrollIntoView). */
139+
/* "Locate" — scroll a control into view (ui.contentControls.scrollIntoView);
140+
"Focus" — scroll AND place the caret inside it (ui.contentControls.focus). */
140141
.clause-actions { display: flex; align-items: center; gap: 8px; }
141-
.locate {
142+
.row-actions { display: flex; align-items: center; gap: 6px; }
143+
.locate,
144+
.focus {
142145
font: inherit;
143146
font-size: var(--sd-font-size-100, 11px);
144147
line-height: 1;
@@ -150,8 +153,10 @@ input:focus {
150153
cursor: pointer;
151154
white-space: nowrap;
152155
}
153-
.locate:hover { color: var(--demo-text); border-color: var(--demo-accent); }
154-
.locate:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; }
156+
.locate:hover,
157+
.focus:hover { color: var(--demo-text); border-color: var(--demo-accent); }
158+
.locate:focus-visible,
159+
.focus:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; }
155160

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

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3838,33 +3838,46 @@ export class PresentationEditor extends EventEmitter {
38383838
entityId: string,
38393839
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
38403840
): Promise<boolean> {
3841+
const pos = this.#resolveContentControlCaretPos(entityId);
3842+
if (pos == null) return false;
3843+
return this.scrollToPositionAsync(pos, {
3844+
behavior: options.behavior ?? 'smooth',
3845+
block: options.block ?? 'center',
3846+
});
3847+
}
3848+
3849+
/**
3850+
* Resolve a caret position inside the content control with `entityId`, or
3851+
* `null` when no such control exists in the body document.
3852+
*
3853+
* Prefers the first *text* position inside the control: only text positions
3854+
* reliably map to a layout fragment; wrapper boundaries (block, paragraph,
3855+
* run) sit between fragments. A deep `descendants` walk handles inline
3856+
* (`run > text`) and block (`paragraph > run > text`) nesting uniformly
3857+
* (`descendants` yields each child's position relative to the node's
3858+
* content, so the absolute position is `found.pos + 1 + rel`). An empty
3859+
* control with no text falls back to the first inside position.
3860+
*
3861+
* The id is normalized to a string before comparing: the id a consumer
3862+
* passes comes from the list / painted `data-sdt-id` (always a string), but
3863+
* the PM attr can be numeric, so a strict `===` would miss it.
3864+
*/
3865+
#resolveContentControlCaretPos(entityId: string): number | null {
38413866
const editor = this.#editor;
3842-
if (!editor || typeof entityId !== 'string' || entityId.length === 0) return false;
3867+
if (!editor || typeof entityId !== 'string' || entityId.length === 0) return null;
38433868

38443869
let found: { pos: number; node: ReturnType<typeof editor.state.doc.nodeAt> } | null = null;
38453870
editor.state.doc.descendants((node, pos) => {
38463871
if (found) return false;
38473872
const name = node.type?.name;
3848-
// Normalize the node id to a string before comparing. The id a
3849-
// consumer passes comes from the list / painted `data-sdt-id` (always
3850-
// a string), but the PM attr can be numeric, so a strict `===` would
3851-
// miss it. Matches the painted-DOM (`getRect`) id convention.
38523873
if ((name === 'structuredContent' || name === 'structuredContentBlock') && String(node.attrs?.id) === entityId) {
38533874
found = { pos, node };
38543875
return false;
38553876
}
38563877
return true;
38573878
});
3858-
if (!found) return false;
3859-
3860-
// Resolve the first *text* position inside the control. Only text
3861-
// positions reliably map to a layout fragment; wrapper boundaries
3862-
// (block, paragraph, run) sit between fragments and make
3863-
// `scrollToPositionAsync` fail. A deep `descendants` walk handles
3864-
// inline (`run > text`) and block (`paragraph > run > text`) nesting
3865-
// uniformly. `descendants` yields each child's position relative to
3866-
// the node's content, so the absolute position is `found.pos + 1 + rel`.
3867-
// Scroll-only: this does NOT move the selection or focus.
3879+
if (!found) return null;
3880+
38683881
let contentPos = found.pos + 1;
38693882
let textFound = false;
38703883
found.node?.descendants((child, rel) => {
@@ -3876,11 +3889,65 @@ export class PresentationEditor extends EventEmitter {
38763889
}
38773890
return true;
38783891
});
3892+
return contentPos;
3893+
}
3894+
3895+
/**
3896+
* Focus a content control (SDT field/clause) by its id: place the caret
3897+
* inside it and scroll it into view — the "take me there and let me edit"
3898+
* counterpart to the scroll-only {@link scrollContentControlIntoView}.
3899+
*
3900+
* Selection, not mutation: locks (`sdtLocked` / `contentLocked` / …) and
3901+
* `viewing` mode do NOT block placing the caret — they still block the edits
3902+
* the user then attempts, via the normal editing rules. So a custom UI can
3903+
* focus a locked clause to let the user inspect it.
3904+
*
3905+
* Caret-inside (not a wrapper NodeSelection): both SDT node types are
3906+
* `atom: false`, so a `TextSelection` inside is the meaningful selection.
3907+
*
3908+
* v1 is body-only: searches the body editor, so a control in a
3909+
* header/footer/note story resolves to `not-found`.
3910+
*
3911+
* @returns `{ success: true }` once focused, or `{ success: false, reason }`
3912+
* for a real navigation problem: `not-ready` (no editor), `invalid-id`
3913+
* (empty id), `not-found` (unknown id / non-body), `not-reachable`
3914+
* (found but the page could not be scrolled into view).
3915+
*/
3916+
async focusContentControl(
3917+
entityId: string,
3918+
options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {},
3919+
): Promise<{ success: true } | { success: false; reason: 'not-ready' | 'invalid-id' | 'not-found' | 'not-reachable' }> {
3920+
const editor = this.#editor;
3921+
if (!editor) return { success: false, reason: 'not-ready' };
3922+
if (typeof entityId !== 'string' || entityId.length === 0) return { success: false, reason: 'invalid-id' };
3923+
3924+
const pos = this.#resolveContentControlCaretPos(entityId);
3925+
if (pos == null) return { success: false, reason: 'not-found' };
3926+
3927+
// Without setTextSelection the editor can't place the caret, so focus
3928+
// can't honor its "caret placed" contract — fail before scrolling.
3929+
if (typeof editor.commands?.setTextSelection !== 'function') {
3930+
return { success: false, reason: 'not-ready' };
3931+
}
38793932

3880-
return this.scrollToPositionAsync(contentPos, {
3933+
// Scroll first and honor the result. A focus that can't bring the control
3934+
// into view must not report success (it would leave a caret on a page that
3935+
// never mounted) — matches #scrollToBlockCandidate. Model-aware: mounts a
3936+
// virtualized page first.
3937+
const scrolled = await this.scrollToPositionAsync(pos, {
38813938
behavior: options.behavior ?? 'smooth',
38823939
block: options.block ?? 'center',
38833940
});
3941+
if (!scrolled) return { success: false, reason: 'not-reachable' };
3942+
3943+
// Place the caret inside the control and honor the result — report success
3944+
// only if the selection was actually placed. setTextSelection clamps and
3945+
// focuses the (hidden) editor view with preventScroll, so keyboard input
3946+
// goes to the control without re-jumping the viewport.
3947+
if (!editor.commands.setTextSelection({ from: pos, to: pos })) {
3948+
return { success: false, reason: 'not-reachable' };
3949+
}
3950+
return { success: true };
38843951
}
38853952

38863953
/**

0 commit comments

Comments
 (0)