diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index 101c3e57d3..c0a94bcce8 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -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 diff --git a/demos/__tests__/contract-templates-focus.spec.ts b/demos/__tests__/contract-templates-focus.spec.ts new file mode 100644 index 0000000000..8520649355 --- /dev/null +++ b/demos/__tests__/contract-templates-focus.spec.ts @@ -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(`[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 }); +}); diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 0a888af144..e104ff8987 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -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(); @@ -393,7 +408,10 @@ function renderFieldsPanel(): void { row.innerHTML = `
- + + + +
`; @@ -401,6 +419,9 @@ function renderFieldsPanel(): void { row.querySelector('.locate')?.addEventListener('click', () => { locateByTag(fieldTag(field.key)); }); + row.querySelector('.focus')?.addEventListener('click', () => { + focusByTag(fieldTag(field.key)); + }); const input = row.querySelector('input'); if (!input) continue; // Reactive: each keystroke debounces ~250ms and fans the value to every @@ -435,6 +456,7 @@ function renderClausesPanel(): void {
Update available +

${escapeHtml(upgrade.summary)}

@@ -469,6 +491,7 @@ function renderClausesPanel(): void {
Current +

Document ${escapeHtml(inDoc)}

@@ -478,6 +501,9 @@ function renderClausesPanel(): void { card.querySelector('.locate')?.addEventListener('click', () => { locateByTag(clauseTag(clause.id, inDoc)); }); + card.querySelector('.focus')?.addEventListener('click', () => { + focusByTag(clauseTag(clause.id, inDoc)); + }); clausesPanelEl.appendChild(card); } } diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index 2945ecabe5..9d7213e80b 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -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; @@ -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; } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 1e05bf270f..72ce1888b0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -3838,33 +3838,46 @@ export class PresentationEditor extends EventEmitter { entityId: string, options: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: ScrollBehavior } = {}, ): Promise { + 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 } | 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) => { @@ -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 }; } /** diff --git a/packages/super-editor/src/ui/content-controls.test.ts b/packages/super-editor/src/ui/content-controls.test.ts index 8886571fef..91948c6f54 100644 --- a/packages/super-editor/src/ui/content-controls.test.ts +++ b/packages/super-editor/src/ui/content-controls.test.ts @@ -396,6 +396,47 @@ describe('ui.contentControls handle (SD-3157)', () => { ui.destroy(); }); + it('focus({ id }) returns { success: false, reason: "invalid-id" } for an empty id without touching the presentation', async () => { + const { superdoc, editor } = makeStub({ items: [makeItem('sdt-1')] }); + const ui = createSuperDocUI({ superdoc }); + const focus = vi.fn().mockResolvedValue({ success: true }); + (editor as { presentationEditor?: unknown }).presentationEditor = { focusContentControl: focus }; + + expect(await ui.contentControls.focus({ id: '' })).toEqual({ success: false, reason: 'invalid-id' }); + expect(focus).not.toHaveBeenCalled(); + + ui.destroy(); + }); + + it('focus({ id }) returns { success: false, reason: "not-ready" } when the presentation layer is not ready', async () => { + const { superdoc } = makeStub({ items: [makeItem('sdt-1')] }); + const ui = createSuperDocUI({ superdoc }); + + expect(await ui.contentControls.focus({ id: 'sdt-1' })).toEqual({ success: false, reason: 'not-ready' }); + + ui.destroy(); + }); + + it('focus({ id }) delegates to the presentation focus with center/smooth defaults and returns its result', async () => { + const { superdoc, editor } = makeStub({ items: [makeItem('sdt-1')] }); + const ui = createSuperDocUI({ superdoc }); + const focus = vi.fn().mockResolvedValue({ success: true }); + (editor as { presentationEditor?: unknown }).presentationEditor = { focusContentControl: focus }; + + expect(await ui.contentControls.focus({ id: 'sdt-1' })).toEqual({ success: true }); + expect(focus).toHaveBeenCalledWith('sdt-1', { block: 'center', behavior: 'smooth' }); + + // Explicit options pass through; the presentation result is returned verbatim. + focus.mockResolvedValueOnce({ success: false, reason: 'not-found' }); + expect(await ui.contentControls.focus({ id: 'nope', block: 'start', behavior: 'auto' })).toEqual({ + success: false, + reason: 'not-found', + }); + expect(focus).toHaveBeenLastCalledWith('nope', { block: 'start', behavior: 'auto' }); + + ui.destroy(); + }); + it('observe receives the snapshot value directly (parallel to comments/trackChanges)', async () => { // `observe` is the value-shaped alias of `subscribe`. The demo // (`field-chip.ts`) consumes it directly, so an explicit test diff --git a/packages/super-editor/src/ui/create-super-doc-ui.ts b/packages/super-editor/src/ui/create-super-doc-ui.ts index e6bbd5efeb..3d61f0b670 100644 --- a/packages/super-editor/src/ui/create-super-doc-ui.ts +++ b/packages/super-editor/src/ui/create-super-doc-ui.ts @@ -67,6 +67,7 @@ import type { ViewportPositionHit, ViewportHandle, ViewportGeometryEvent, + ContentControlFocusResult, ViewportRect, ViewportRectResult, } from './types.js'; @@ -2392,6 +2393,33 @@ export function createSuperDocUI(options: SuperDocUIOptions): SuperDocUI { }); return { success: Boolean(ok) }; }, + async focus({ + id, + block, + behavior, + }: { + id: string; + block?: 'start' | 'center' | 'end' | 'nearest'; + behavior?: 'auto' | 'smooth'; + }): Promise { + if (typeof id !== 'string' || id.length === 0) return { success: false, reason: 'invalid-id' }; + // Same host-editor resolution as scrollIntoView. focus places the caret + // (selection) and scrolls; locks / viewing mode don't block it. + const editor = resolveHostEditor(superdoc); + const presentation = editor?.presentationEditor as + | { + focusContentControl?: ( + id: string, + opts: { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: 'auto' | 'smooth' }, + ) => Promise; + } + | null + | undefined; + if (!presentation || typeof presentation.focusContentControl !== 'function') { + return { success: false, reason: 'not-ready' }; + } + return presentation.focusContentControl(id, { block: block ?? 'center', behavior: behavior ?? 'smooth' }); + }, }; // Resolve a metadata id (= the SDT's w:tag) to the SDT's content- diff --git a/packages/super-editor/src/ui/types.ts b/packages/super-editor/src/ui/types.ts index b1bac10f5e..cf3f94c665 100644 --- a/packages/super-editor/src/ui/types.ts +++ b/packages/super-editor/src/ui/types.ts @@ -519,8 +519,38 @@ export interface ContentControlsHandle { block?: 'start' | 'center' | 'end' | 'nearest'; behavior?: 'auto' | 'smooth'; }): Promise; + /** + * Focus the content control identified by `id`: place the caret inside it + * and scroll it into view — the "take me there and let me edit" counterpart + * to {@link scrollIntoView} (which is scroll-only). `block` defaults to + * `'center'`, `behavior` to `'smooth'`. + * + * Selection, not mutation: it does NOT bypass lock or document-mode rules. + * If the control is locked or the document is read-only, the user can + * inspect it, but edits are still blocked by the normal editing rules. + * + * Resolves to `{ success: false, reason }` only for real navigation + * problems — `'invalid-id'` (empty id), `'not-ready'` (no presentation + * layer), `'not-found'` (no such control in the body document; v1 is + * body-only), or `'not-reachable'` (found, but its page couldn't be + * scrolled into view). Lock mode and viewing mode never make it fail. + */ + focus(input: { + id: string; + block?: 'start' | 'center' | 'end' | 'nearest'; + behavior?: 'auto' | 'smooth'; + }): Promise; } +/** + * Result of {@link ContentControlsHandle.focus}. Fails only for real + * navigation problems, never for lock mode or viewing mode (focus is + * selection, not mutation). + */ +export type ContentControlFocusResult = + | { success: true } + | { success: false; reason: 'invalid-id' | 'not-ready' | 'not-found' | 'not-reachable' }; + /** * Anchored-metadata domain handle exposed on `ui.metadata`. Sugar over * the metadata-id → content-control-id → painter geometry bridge that