diff --git a/demos/__tests__/contract-templates-chip-anchor.spec.ts b/demos/__tests__/contract-templates-chip-anchor.spec.ts deleted file mode 100644 index cf16ec6558..0000000000 --- a/demos/__tests__/contract-templates-chip-anchor.spec.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * SD-3311 regression: the field chip must stay anchored to its active control - * after a geometry change that fires NO scroll event (zoom). The chip is a - * fixed-position overlay positioned from `ui.contentControls.getRect()`. Today - * field-chip only re-anchors on active-change / scroll / resize, so a zoom - * leaves it stranded (verified: ~230px drift). This is RED until - * `ui.viewport.observe()` lands and field-chip re-queries on it. - * - * Runs only for the contract-templates demo (the shared suite runs once per DEMO). - */ - -test.use({ viewport: { width: 1280, height: 800 } }); - -test('field chip stays anchored to its control after a zoom (no-scroll geometry change)', 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( - () => { - const ui = (window as any).__demo?.state?.ui; - return !!ui && ui.contentControls.getSnapshot().items.length > 0; - }, - null, - { timeout: 30_000 }, - ); - - // Activate the first inline smart field so the chip appears and anchors. - await page.waitForSelector('.superdoc-structured-content-inline[data-sdt-id]'); - await page.locator('.superdoc-structured-content-inline[data-sdt-id]').first().click(); - await page.locator('.sd-field-chip').waitFor({ state: 'visible', timeout: 10_000 }); - - // Horizontal gap between the chip's left edge and its active control's left - // edge. positionChip sets chip.left = control.left, so this is ~0 when anchored. - const probe = () => - page.evaluate(() => { - const ui = (window as any).__demo.state.ui; - const activeId = ui.contentControls.getSnapshot().activeId as string | null; - const chip = document.querySelector('.sd-field-chip'); - const ctrl = activeId ? document.querySelector(`[data-sdt-id="${activeId}"]`) : null; - if (!chip || !ctrl) return null; - const c = chip.getBoundingClientRect(); - const k = ctrl.getBoundingClientRect(); - return { dxLeft: Math.abs(c.left - k.left), ctrlLeft: Math.round(k.left) }; - }); - - const before = await probe(); - expect(before, 'chip + active control both resolve').not.toBeNull(); - expect(before!.dxLeft, 'chip starts anchored to the control').toBeLessThanOrEqual(2); - - // Zoom: a geometry change with no scroll event. - await page.evaluate(() => (window as any).__demo.superdoc.setZoom(150)); - - // Poll for the settled state: the control has moved (zoom applied) AND the - // chip has re-anchored to it. Polling absorbs the rAF/repaint delay between - // the geometry change and the viewport.observe -> positionChip re-query. - // Without the SD-3311 fix this stays "drift:~230" and times out. - await expect - .poll( - async () => { - const p = await probe(); - if (!p) return 'no-probe'; - if (p.ctrlLeft === before!.ctrlLeft) return 'control-not-moved'; - return p.dxLeft <= 2 ? 'anchored' : `drift:${Math.round(p.dxLeft)}`; - }, - { timeout: 6_000 }, - ) - .toBe('anchored'); -}); diff --git a/demos/__tests__/contract-templates-focus.spec.ts b/demos/__tests__/contract-templates-focus.spec.ts index 8520649355..87e11a3b81 100644 --- a/demos/__tests__/contract-templates-focus.spec.ts +++ b/demos/__tests__/contract-templates-focus.spec.ts @@ -25,7 +25,8 @@ test('clicking a field Focus places the caret inside that control', async ({ pag { timeout: 30_000 }, ); - // Fields tab is the default; the Focus buttons live on field rows. + // Field value rows (with the Focus buttons) live on the Values tab. + await page.click('.tab[data-tab="values"]'); await page.waitForSelector('[data-focus-field]'); const key = await page.getAttribute('[data-focus-field]', 'data-focus-field'); expect(key).toBeTruthy(); @@ -62,80 +63,3 @@ test('clicking a field Focus places the caret inside that control', async ({ pag // 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/__tests__/contract-templates-locate.spec.ts b/demos/__tests__/contract-templates-locate.spec.ts deleted file mode 100644 index 40d9e76016..0000000000 --- a/demos/__tests__/contract-templates-locate.spec.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test, expect } from '@playwright/test'; - -/** - * Demo smoke for the contract-templates "Locate" affordance (dogfoods - * `ui.contentControls.scrollIntoView`): clicking a lower clause's Locate - * button scrolls that control's painted element into view. - * - * The shared suite runs once per DEMO, so this skips for every other demo. - */ - -// A short viewport so the bottom clause starts below the fold. -test.use({ viewport: { width: 1100, height: 520 } }); - -test('clicking a lower clause Locate scrolls its content control into view', async ({ page }) => { - test.skip(process.env.DEMO !== 'contract-templates', 'contract-templates demo only'); - - const errors: string[] = []; - page.on('pageerror', (e) => errors.push(e.message)); - page.on('console', (m) => { - if (m.type() === 'error') errors.push(m.text()); - }); - await page.route('**/ingest.superdoc.dev/**', (r) => - r.fulfill({ status: 204, contentType: 'application/json', body: '{}' }), - ); - - await page.goto('/'); - await expect(page.locator('body')).toBeVisible({ timeout: 30_000 }); - - // Wait until SuperDoc has imported the fixture and the UI handle sees controls. - await page.waitForFunction( - () => { - const ui = (window as unknown as { __demo?: { state?: { ui?: unknown } } }).__demo?.state?.ui as - | { contentControls: { getSnapshot(): { items: unknown[] } } } - | undefined; - return !!ui && ui.contentControls.getSnapshot().items.length > 0; - }, - null, - { timeout: 30_000 }, - ); - - // Locate buttons on clause cards live in the (initially hidden) clauses panel. - await page.click('.tab[data-tab="clauses"]'); - await page.waitForSelector('[data-locate-clause]'); - - // Resolve the bottom-most block clause: its painted id (data-sdt-id) and its - // sectionId (= the Locate button's data-locate-clause). - const target = await page.evaluate(() => { - const ui = (window as unknown as { __demo: { state: { ui: { contentControls: { getSnapshot(): { items: Array<{ id: string; kind: string; properties?: { tag?: string } }> } } } } } }).__demo.state.ui; - const items = ui.contentControls.getSnapshot().items; - const blocks = items.filter((i) => 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(); - - const inViewport = () => - page.evaluate((id) => { - const el = document.querySelector(`[data-sdt-id="${id}"]`); - if (!el) return false; - const r = el.getBoundingClientRect(); - return r.top >= 0 && r.top <= window.innerHeight; - }, target.id); - - // The bottom clause starts off-screen. - expect(await inViewport()).toBe(false); - - // Click its Locate button; the document should scroll it into view. - await page.click(`[data-locate-clause="${target.sectionId}"]`); - await expect.poll(inViewport, { timeout: 5_000 }).toBe(true); - - expect(errors).toEqual([]); -}); diff --git a/demos/__tests__/contract-templates-smart-tags.spec.ts b/demos/__tests__/contract-templates-smart-tags.spec.ts new file mode 100644 index 0000000000..face61bf61 --- /dev/null +++ b/demos/__tests__/contract-templates-smart-tags.spec.ts @@ -0,0 +1,399 @@ +import { test, expect } from '@playwright/test'; + +/** + * Smart-tags authoring: clicking a tag chip in the sidebar inserts a matching + * inline SDT at the caret (dogfoods ui.selection.capture + create.contentControl). + * The inserted control is created EMPTY (shows the placeholder) and contentLocked + * (values are filled only via the Values form), carries the field's tag, and + * paints with the same .superdoc-structured-content-inline wrapper the chips match. + * + * Runs only for the contract-templates demo (the shared suite runs once per DEMO). + */ + +test('clicking a Smart-tags chip inserts a matching inline SDT at the caret', 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 }, + ); + await page.waitForSelector('[data-tag-key]'); + + // Place a caret in the document body so capture() has an insertion point. + await page.evaluate(() => { + (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); + }); + + const key = await page.getAttribute('[data-tag-key]', 'data-tag-key'); + expect(key).toBeTruthy(); + + // Count existing controls with this tag, then click the chip and expect one more. + // (The field is inserted empty/locked, so we count by tag, not by text.) + const tag = JSON.stringify({ kind: 'smartField', key }); + const countForTag = () => + page.evaluate((t) => { + const ed = (window as any).__demo.superdoc.activeEditor; + let n = 0; + ed.state.doc.descendants((node: any) => { + if (node.type.name === 'structuredContent' && node.attrs?.tag === t) n += 1; + return true; + }); + return n; + }, tag); + + const before = await countForTag(); + await page.click(`[data-tag-key="${key}"]`); + await expect.poll(countForTag, { timeout: 6_000 }).toBe(before + 1); +}); + +test('clicking an in-editor smart-field token highlights its sidebar chip', 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 }, + ); + await page.waitForSelector('[data-tag-key]'); + + const sel = '.superdoc-structured-content-inline[data-sdt-tag*="smartField"]'; + await page.waitForSelector(sel); + // The key of the first painted inline smart-field token in the document. + const key = await page.evaluate((s) => { + const el = document.querySelector(s); + try { + return JSON.parse(el?.getAttribute('data-sdt-tag') ?? '{}').key ?? null; + } catch { + return null; + } + }, sel); + expect(key).toBeTruthy(); + + // Click the token in the document; its sidebar chip should become active. + await page.locator(sel).first().click(); + await expect + .poll( + async () => + page.evaluate( + (k) => document.querySelector(`.smart-tag[data-tag-key="${k}"]`)?.classList.contains('is-active') ?? false, + key, + ), + { timeout: 5_000 }, + ) + .toBe(true); +}); + +test('a smart-field pill does not shift its box on hover or click (no jitter)', 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 }, + ); + const sel = '.superdoc-structured-content-inline[data-sdt-tag*="smartField"]'; + await page.waitForSelector(sel); + + // Under chrome:'none' SuperDoc resets the field's border/fill on hover and on + // selectednode; the demo re-asserts them to keep the box. Guard that the box + // and border stay constant across rest -> hover -> click, so it never moves. + const box = () => + page.evaluate((s) => { + const el = document.querySelector(s) as HTMLElement; + const r = el.getBoundingClientRect(); + return { w: Math.round(r.width), h: Math.round(r.height), border: getComputedStyle(el).borderTopWidth }; + }, sel); + + const rest = await box(); + await page.locator(sel).first().hover(); + await page.waitForTimeout(250); + const hovered = await box(); + await page.locator(sel).first().click(); + await page.waitForTimeout(250); + const clicked = await box(); + + for (const state of [hovered, clicked]) { + expect(state.border).toBe('1px'); + expect(Math.abs(state.w - rest.w)).toBeLessThanOrEqual(1); + expect(Math.abs(state.h - rest.h)).toBeLessThanOrEqual(1); + } +}); + +test('a block clause keeps its left rail and box across hover/select (no jitter)', 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 }, + ); + const sel = '.superdoc-structured-content-block[data-sdt-tag*="reusableSection"]'; + await page.waitForSelector(sel); + + // Block SDTs strip border + fill on .sdt-group-hover / .ProseMirror-selectednode; + // the demo overrides them. Guard the 4px left rail and box stay constant. + const box = () => + page.evaluate((s) => { + const el = document.querySelector(s) as HTMLElement; + const r = el.getBoundingClientRect(); + return { rail: getComputedStyle(el).borderLeftWidth, w: Math.round(r.width), h: Math.round(r.height) }; + }, sel); + + const rest = await box(); + expect(rest.rail).toBe('4px'); + await page.locator(sel).first().hover(); + await page.waitForTimeout(250); + const hovered = await box(); + await page.locator(sel).first().click(); + await page.waitForTimeout(250); + const clicked = await box(); + + for (const state of [hovered, clicked]) { + expect(state.rail).toBe('4px'); + expect(Math.abs(state.w - rest.w)).toBeLessThanOrEqual(1); + expect(Math.abs(state.h - rest.h)).toBeLessThanOrEqual(1); + } +}); + +test('smart fields are contentLocked and fill only through the Values form', 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 }, + ); + + const smartFieldLockModes = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + return doc.contentControls + .list({}) + .items.filter((c: any) => { + try { + return JSON.parse(c.properties?.tag ?? '{}').kind === 'smartField'; + } catch { + return false; + } + }) + .map((c: any) => c.lockMode); + }); + const textForDisclosingParty = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const tag = JSON.stringify({ kind: 'smartField', key: 'disclosingParty' }); + return doc.contentControls.selectByTag({ tag }).items.map((c: any) => c.text); + }); + + // Every smart field starts contentLocked (the user can't type into them). + const before = await smartFieldLockModes(); + expect(before.length).toBeGreaterThan(0); + expect(before.every((m) => m === 'contentLocked')).toBe(true); + + // Editing through the Values form writes through the lock (unlock -> setValue + // -> relock): the field text updates even though the control is locked. Use a + // value distinct from the seeded default so the write is observable. + await page.click('.tab[data-tab="values"]'); + await page.fill('input[data-field="disclosingParty"]', 'Globex Corporation'); + await expect.poll(textForDisclosingParty, { timeout: 6_000 }).toContain('Globex Corporation'); + + // And the controls are relocked afterward, never left editable. + const after = await smartFieldLockModes(); + expect(after.every((m) => m === 'contentLocked')).toBe(true); +}); + +test('block clauses are contentLocked too', 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 blocks are locked like the inline fields, so their prose can't be + // edited by typing in the document. + const clauseLockModes = await page.evaluate(() => { + const doc = (window as any).__demo.doc(); + return doc.contentControls + .list({}) + .items.filter((c: any) => { + try { + return JSON.parse(c.properties?.tag ?? '{}').kind === 'reusableSection'; + } catch { + return false; + } + }) + .map((c: any) => c.lockMode); + }); + expect(clauseLockModes.length).toBeGreaterThan(0); + expect(clauseLockModes.every((m) => m === 'contentLocked')).toBe(true); +}); + +test('a field value broadcasts to every occurrence, including one nested in a locked clause', 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 }, + ); + + // Receiving party appears twice: once in the header sentence and once nested + // inside the (locked) Permitted Use clause. The clause's content lock silently + // vetoes writes to the nested one unless the clause is unlocked around the + // write - this guards that the form value reaches BOTH occurrences. + const receivingPartyTexts = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const tag = JSON.stringify({ kind: 'smartField', key: 'receivingParty' }); + return doc.contentControls.selectByTag({ tag }).items.map((c: any) => c.text); + }); + + expect((await receivingPartyTexts()).length).toBe(2); + + await page.click('.tab[data-tab="values"]'); + await page.fill('input[data-field="receivingParty"]', 'Beacon Bio'); + + await expect + .poll(async () => (await receivingPartyTexts()).filter((t) => t === 'Beacon Bio').length, { timeout: 6_000 }) + .toBe(2); +}); + +test('the clause library is single-use: seeded clauses are In contract, others Add clause', 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 }, + ); + await page.waitForSelector('.clause[data-clause-id]'); + + // A seeded clause is already in the contract; a library-only one is available. + await expect(page.locator('.clause[data-clause-id="permittedUse"] .clause-status')).toHaveText('In contract'); + await expect(page.locator('.clause[data-clause-id="permittedUse"]')).toHaveClass(/is-present/); + await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('Add clause'); + await expect(page.locator('.clause[data-clause-id="indemnification"]')).toHaveClass(/is-available/); +}); + +test('clicking an available clause adds it once (single-use, then In contract)', 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 }, + ); + await page.waitForSelector('.clause[data-clause-id="indemnification"]'); + + // Caret in the (unlocked) title so the clause adds at a clean block boundary. + await page.evaluate(() => { + (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); + }); + + const indemnificationInfo = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const items = doc.contentControls.list({}).items.filter((c: any) => { + try { + return JSON.parse(c.properties?.tag ?? '{}').sectionId === 'indemnification'; + } catch { + return false; + } + }); + return { count: items.length, allLocked: items.every((c: any) => c.lockMode === 'contentLocked') }; + }); + + expect((await indemnificationInfo()).count).toBe(0); + await page.click('.clause[data-clause-id="indemnification"]'); + + // It's added once, locked, and the card flips to In contract. + await expect.poll(async () => (await indemnificationInfo()).count, { timeout: 6_000 }).toBe(1); + expect((await indemnificationInfo()).allLocked).toBe(true); + await expect(page.locator('.clause[data-clause-id="indemnification"] .clause-status')).toHaveText('In contract'); + + // Clicking again does NOT duplicate it (single-use; reveals the existing one). + await page.click('.clause[data-clause-id="indemnification"]'); + await page.waitForTimeout(500); + expect((await indemnificationInfo()).count).toBe(1); +}); + +test('adding the Return of Materials clause nests a real smart field that fills from the form', 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 }, + ); + await page.waitForSelector('.clause[data-clause-id="returnOfMaterials"]'); + + // Caret in the (unlocked) title so the clause adds at a clean block boundary. + await page.evaluate(() => { + (window as any).__demo.superdoc.activeEditor.commands?.setTextSelection?.({ from: 6, to: 6 }); + }); + + // Receiving party smart fields in the document (Return of Materials carries one). + const receivingPartyControls = () => + page.evaluate(() => { + const doc = (window as any).__demo.doc(); + const tag = JSON.stringify({ kind: 'smartField', key: 'receivingParty' }); + return doc.contentControls.selectByTag({ tag }).items.map((c: any) => c.text); + }); + + const before = (await receivingPartyControls()).length; // 2 seeded + await page.click('.clause[data-clause-id="returnOfMaterials"]'); + + // Adding the clause creates a real nested Receiving party SDT (not plain text). + await expect.poll(async () => (await receivingPartyControls()).length, { timeout: 6_000 }).toBe(before + 1); + + // Filling Receiving party in the Values form reaches every occurrence, + // including the one just nested inside the added clause. + await page.click('.tab[data-tab="values"]'); + await page.fill('input[data-field="receivingParty"]', 'Beacon Bio'); + await expect + .poll(async () => (await receivingPartyControls()).filter((t) => t === 'Beacon Bio').length, { timeout: 6_000 }) + .toBe(before + 1); +}); diff --git a/demos/contract-templates/README.md b/demos/contract-templates/README.md index fcd57ef29b..d2d974512d 100644 --- a/demos/contract-templates/README.md +++ b/demos/contract-templates/README.md @@ -1,20 +1,29 @@ # Contract templates -Runtime contract template management built on Word content controls. A Mutual NDA opens with tagged smart fields and six versioned clauses. The app detects stale clauses against a library, updates them in place, and exports either a raw template DOCX or a clean final DOCX. Single-page, no backend, no framework. +Build your own UI for Word content controls (SDT fields) on top of SuperDoc. SuperDoc's built-in field chrome is off (`modules: { contentControls: { chrome: 'none' } }`), so you paint the field and clause look yourself and drive every interaction through the public surface: `editor.doc.*` and `superdoc/ui`. The document stays a real, Word-compatible `.docx` that round-trips. Single page, no backend, no framework. -This is a demo: it composes multiple content-control patterns into a product workflow. For the smallest copy-pasteable primitive, see the [tagged inline text example](../../examples/document-api/content-controls/tagged-inline-text). +The model is a locked template you assemble from a component library. A Mutual NDA opens with its fields and clauses already in place. The document is a locked surface: you can't change a control by typing in it. Instead you drag building blocks in from the sidebar and fill values through a form. Every change goes through the public API. -## What this shows +## What it shows -The starting document is a Mutual NDA at `public/nda-template.docx` with thirteen content controls already in place: seven inline plain-text controls (smart fields) and six block rich-text controls (reusable clauses). Receiving party and Purpose each appear twice — once in the header sentence and once nested inside the Permitted Use clause. Each control carries a `w:tag` with a JSON payload. On boot, SuperDoc imports the DOCX, parses the SDTs, and the demo reads field values and clause versions straight from the parsed controls. +The starting document is `public/nda-template.docx`: inline plain-text fields and six block rich-text clauses, each carrying a `w:tag` with a JSON payload (`{ kind: 'smartField', key }` or `{ kind: 'reusableSection', sectionId }`). Receiving party and Purpose appear twice, in the header sentence and nested inside the Permitted Use clause. -Three flows of the same primitive, composed into one app: +**Locked controls.** On load, every field and clause is set to `contentLocked` (`ui.contentControls.setLockMode`). You can't change a value or a clause by typing in the document. This is the template surface; the custom UI drives all edits. -1. **Smart fields.** Seven inline plain-text content controls across five field keys share a `tag` shape (`{ kind: 'smartField', key: 'disclosingParty' }`) per occurrence. They were authored as Word "Plain Text Content Controls" (`ContentControls.Add(1, range)`), so SuperDoc resolves them as `controlType: 'text'`. Edit a value in the Fields tab; every occurrence of that field updates live via `selectByTag` + per-occurrence `text.setValue`. Receiving party and Purpose appear twice (header sentence and nested inside the Permitted Use clause), so a single edit fans across both locations. -2. **Versioned reusable clauses.** Six block rich-text content controls carry `{ kind: 'reusableSection', sectionId, version }` in their tags. They were authored as Word "Rich Text Content Controls" (`ContentControls.Add(0, range)`), which produces typeless sdtPr; SuperDoc resolves them as `controlType: 'richText'` per ECMA-376 §17.5.2.26. The app reads each live version from `contentControls.list`, compares against the clause library, and surfaces a Review CTA when they diverge. Review expands a card with the current clause text alongside the library clause text plus a Replace with library clause action that calls `replaceContent` + `patch`. -3. **Export.** `superdoc.export({ exportedName, isFinalDoc, triggerDownload })` has two buttons: **Export raw DOCX** uses `isFinalDoc: false` to preserve content controls and tags for future template/library updates; **Export clean DOCX** uses `isFinalDoc: true` to flatten controls so the filled values are in place. +**Template tab, the building-block library.** Two catalogs, fields and clauses, each styled to match what it inserts: -Every mutation goes through `editor.doc.*`. The same operation set runs headless via the Node SDK and CLI. +- Smart-field chips wear the same blue token look as the in-document field (CSS on `.superdoc-structured-content-inline[data-sdt-tag*='smartField']`). Drag a chip onto the document, or click to insert it at the cursor. An unfilled field shows its field-name token (e.g. `DISCLOSING_PARTY`) as a stand-in placeholder. That token is literal text content, not a native SDT placeholder. +- Clause cards wear the same blue block look as the in-document clause and carry metadata (category, jurisdiction, version) and a status. A clause is single-use, like an inclusion checklist: a card already in the contract reads **In contract** and clicking it reveals the existing clause; an available card reads **Add clause** and drags or clicks in. The catalog includes clauses that aren't in the document yet (e.g. Indemnification, Return of Materials). + +Inserts resolve the drop point with `ui.viewport.positionAt({ x, y })` and create the control with `editor.doc.create.contentControl({ kind, at, content, tag, lockMode })`. A field inserts inline at the exact caret; a clause snaps to a block boundary so it lands as a clean section instead of splitting a paragraph. Clicking a control in the document highlights its chip or card (`content-control:click`). + +A clause is assembled from structured `parts`: prose plus `{ field }` slots. Inserting "Permitted Use" creates the block and then wraps each slot as a nested, locked inline smart field, so the inserted clause carries real Receiving party and Purpose fields, just like the seeded one. Filling those fields in the Values tab updates the clause and the header sentence together. + +**Values tab, fill the fields.** Edit a value and it fans to every occurrence of that field, including the ones nested inside a locked clause. Each write briefly unlocks the clauses, sets the value (`selectByTag` + `text.setValue`), then relocks them. A clause's content lock otherwise silently vetoes writes to anything nested in it, so without this the nested occurrence would never update. The form is the only way to change a value. + +**Export.** `superdoc.export({ exportedName, isFinalDoc, triggerDownload })`: raw DOCX keeps the controls and tags; clean DOCX flattens them so the filled values are in place. + +Every mutation goes through `editor.doc.*`, so the same operations run headless via the Node SDK and CLI. ## Run @@ -23,17 +32,19 @@ pnpm install pnpm dev ``` -The seeded NDA ships with three clauses behind their latest versions (Confidentiality, Governing Law, Limitation of Liability). The Clauses tab shows a Review CTA on each; expanding a card lets you compare the in-document clause with the library version and replace it in place. Edit a value in the Fields tab and watch it fan to every occurrence in the document (header and nested locations). Export raw DOCX when you want to keep the template controls, or export clean DOCX when you want a final document with the values in place. +Open the Template tab. Drag a field or clause into the document, or click one to insert it at the cursor. Switch to the Values tab and edit a value; it updates every occurrence, header and nested. Export raw DOCX to keep the controls, or clean DOCX for a final document. -## Related work +## Honest limits -If you need a **ready-made React component for authoring templates** with content controls (`{{` trigger menu, linked field groups, owner/signer field types, DOCX export), see [`@superdoc-dev/template-builder`](https://docs.superdoc.dev/solutions/template-builder/introduction). This demo focuses on the *runtime* side: an app filling and updating already-tagged regions. Template Builder focuses on the *authoring* side. +- An inserted clause is a single paragraph of prose with field slots. Multi-paragraph clauses, lists, tables, or other formatting inside a clause aren't modeled here; the slots become inline text fields. (The block control is a `richText` SDT, so richer bodies are possible; this demo just doesn't author them.) +- A drop snaps to the start of the block under the cursor, so a clause lands at a block boundary rather than at the exact pixel. +- The placeholder shown in an unfilled field is the field-name token, set as content. SuperDoc's native empty-control placeholder text is renderer-hardcoded and not settable through the API. +- Every control is `contentLocked`. The demo doesn't exercise `sdtLocked` or `sdtContentLocked`. +- Clause version review / replace (detect an outdated clause, swap in the library text) is intentionally out of scope. This demo proves template assembly, not the clause lifecycle. -## Honest limits +## Related work -- All content controls in the fixture are `unlocked`. Locked controls (`sdtLocked`, `sdtContentLocked`) are not driven programmatically here. -- Smart field values are pushed through `text.setValue` (the typed API for plain-text controls). Clause bodies are pushed through `replaceContent` because rich-text controls don't have a typed setter. -- Clause bodies in the seeded fixture are single-paragraph plain prose; the rich-text wrapper supports formatting/lists/tables when authored that way, but the demo doesn't exercise those. +If you need a ready-made React component for authoring templates with content controls (`{{` trigger menu, linked field groups, owner/signer field types, DOCX export), see [`@superdoc-dev/template-builder`](https://docs.superdoc.dev/solutions/template-builder/introduction). This demo shows how to build that kind of UI yourself on the public API. ## See also diff --git a/demos/contract-templates/index.html b/demos/contract-templates/index.html index cba7044f13..ba843bb2e7 100644 --- a/demos/contract-templates/index.html +++ b/demos/contract-templates/index.html @@ -15,15 +15,16 @@ +
diff --git a/demos/contract-templates/src/field-chip.ts b/demos/contract-templates/src/field-chip.ts deleted file mode 100644 index a6311ce52e..0000000000 --- a/demos/contract-templates/src/field-chip.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Contextual smart-field chip — SD-3157 / SD-3232 demo. - * - * Shows a small chip anchored above the active smart-field content - * control with the field's label and current value. Plain TypeScript - * (no framework), wired against two public SuperDoc APIs: - * - * - `superdoc.on('content-control:active-change', ...)` to know *which* - * control is active (SD-3232 events). The payload's `SdtRef` carries - * the tag/alias/scope directly, so no extra lookup is needed. - * - `ui.contentControls.getRect({ id })` to know *where* to draw the chip. - * - * That pairing is the intended model: events tell you what is active; - * `getRect()` tells you where to place your own UI. - * - * Narrow on purpose: only renders for `kind: 'smartField'` controls so - * the chip doesn't collide with the block-clause review UI in the Clauses - * tab. Linked-occurrence highlights, field-details popovers, and clause - * badges are deliberate follow-ups (SD-3155 umbrella). - * - * The demo runs with SuperDoc's built-in SDT chrome turned off - * (`modules.contentControls.chrome: 'none'`, SD-3159), so the chip is the - * smart field's active-state UI rather than an addition on top of the - * built-in blue label/border. The wrappers and data-sdt-* datasets are - * still emitted, which is what `getRect` relies on. - */ -import type { SuperDoc, ContentControlActiveChangePayload } from 'superdoc'; -import type { SuperDocUI } from 'superdoc/ui'; - -export type SmartFieldLookup = { - /** Human label for a smart-field key (e.g. `disclosingParty` → `Disclosing party`). */ - labelFor(key: string): string; - /** Current value tracked by the host demo (mirrors live SDT text). */ - valueFor(key: string): string | undefined; -}; - -const CHIP_CLASS = 'sd-field-chip'; -const CHIP_OFFSET_PX = 6; - -/** - * Wire the chip. `superdoc` supplies the active-change events; `ui` - * supplies `getRect` for positioning. Returns a teardown function that - * detaches listeners and removes the chip element. Safe to call after - * `initialize()` has populated the field-value cache. - */ -export function attachFieldChip(superdoc: SuperDoc, ui: SuperDocUI, lookup: SmartFieldLookup): () => void { - const chipEl = document.createElement('div'); - chipEl.className = CHIP_CLASS; - chipEl.style.position = 'fixed'; - chipEl.style.visibility = 'hidden'; - chipEl.style.pointerEvents = 'none'; - chipEl.style.zIndex = '20'; - document.body.appendChild(chipEl); - - let currentId: string | null = null; - let currentKey: string | null = null; - - /** - * Clear the active control entirely. Use ONLY when active-change tells - * us "no active smart field" (active is null, or not a smart field). Do - * NOT call this from the positioning loop on a transient rect miss (a - * reflow can drop the rect for one tick; clearing here would leave the - * chip hidden until the user clicks away and back). - */ - const clearActive = () => { - chipEl.style.visibility = 'hidden'; - currentId = null; - currentKey = null; - }; - - /** Hide visually but keep the active state, so the next tick can re-anchor. */ - const hideVisually = () => { - chipEl.style.visibility = 'hidden'; - }; - - const positionChip = () => { - if (!currentId) return; - const rect = ui.contentControls.getRect({ id: currentId }); - if (!rect.success) { - // Transient miss — keep the active state so the next scroll / resize - // tick can re-anchor without requiring the user to click away. - hideVisually(); - return; - } - // Position the chip above the wrapper. Falls below if there's no - // room — keeps it on-screen during scroll-to-top behavior. - const { rect: r } = rect; - chipEl.style.visibility = 'visible'; - chipEl.style.left = `${r.left}px`; - const wantedTop = r.top - chipEl.offsetHeight - CHIP_OFFSET_PX; - chipEl.style.top = `${wantedTop >= 0 ? wantedTop : r.top + r.height + CHIP_OFFSET_PX}px`; - }; - - const renderChip = (label: string, value: string) => { - const valueStr = value.length > 0 ? value : '(empty)'; - chipEl.innerHTML = ''; - const labelSpan = document.createElement('span'); - labelSpan.className = `${CHIP_CLASS}__label`; - labelSpan.textContent = label; - const dot = document.createTextNode(' · '); - const valueSpan = document.createElement('span'); - valueSpan.className = `${CHIP_CLASS}__value`; - valueSpan.textContent = valueStr; - chipEl.appendChild(labelSpan); - chipEl.appendChild(dot); - chipEl.appendChild(valueSpan); - }; - - const update = () => { - if (!currentId || !currentKey) { - clearActive(); - return; - } - renderChip(lookup.labelFor(currentKey), lookup.valueFor(currentKey) ?? ''); - positionChip(); - }; - - // Re-anchor whenever the viewport geometry changes. ui.viewport.observe is - // the single signal for this - it fires on scroll, resize, zoom, and - // layout/pagination reflow, so we catch the zoom / reflow cases that - // hand-wired window scroll + resize listeners miss (SD-3311). - const onViewportChange = () => positionChip(); - - // SD-3232: the active control comes from the public SuperDoc event. The - // payload includes the SdtRef (id + tag), so we can narrow to smart - // fields and anchor by id without a separate lookup. - const onActiveChange = ({ active }: ContentControlActiveChangePayload) => { - if (!active) { - clearActive(); - return; - } - // Narrow to smart-field SDTs only. Block-level reusable clauses have - // their own review surface in the Clauses tab; a chip on them would - // compete with that flow. - const tagStr = active.tag; - if (!tagStr) { - clearActive(); - return; - } - let parsed: { kind?: unknown; key?: unknown } | null = null; - try { - parsed = JSON.parse(tagStr); - } catch { - clearActive(); - return; - } - if (!parsed || parsed.kind !== 'smartField' || typeof parsed.key !== 'string') { - clearActive(); - return; - } - currentId = active.id; - currentKey = parsed.key; - update(); - }; - - superdoc.on('content-control:active-change', onActiveChange); - const unobserveViewport = ui.viewport.observe(onViewportChange); - - return () => { - superdoc.off('content-control:active-change', onActiveChange); - unobserveViewport(); - chipEl.remove(); - }; -} diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index e104ff8987..adc6cd3d75 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -1,51 +1,59 @@ /** - * Contract templates: a runtime workflow on Word content controls. + * Contract templates: build a contract template on Word content controls (SDTs) + * with a fully custom UI. SuperDoc's built-in SDT chrome is off + * (`modules.contentControls.chrome: 'none'`), so the demo paints the field/clause + * look itself (style.css) and drives every interaction through the public + * surface: `editor.doc.*` and `superdoc/ui` (events, viewport.positionAt, + * contentControls.scrollIntoView / focus / setLockMode). * - * The document is a Mutual NDA (`public/nda-template.docx`) - * with content controls already in place: - * - Seven inline plain-text content controls across five field keys - * (disclosing party, receiving party, effective date, purpose, term - * length). Authored via Word's `ContentControls.Add(1, range)`, so their - * `w:sdtPr` carries `` and they resolve as `controlType: 'text'`. - * Receiving party and Purpose each appear twice: once in the header - * sentence and once nested inside the Permitted Use block clause. - * - Six block rich-text content controls (Preamble, Confidentiality, - * Permitted Use, Term and Termination, Governing Law, Limitation of - * Liability). Authored via `ContentControls.Add(0, range)`, which - * produces typeless sdtPr that resolves as `controlType: 'richText'` - * per ECMA-376 §17.5.2.26. Each block carries - * `{ kind: 'reusableSection', sectionId, version }` in its tag. + * The starting document is a Mutual NDA (`public/nda-template.docx`) with + * controls already in place: + * - Inline plain-text fields across five keys (disclosing party, receiving + * party, effective date, purpose, term). Receiving party and Purpose each + * appear twice: in the header sentence and nested inside the Permitted Use + * block clause. + * - Six block rich-text clauses tagged `{ kind: 'reusableSection', sectionId }`. * - * The app: - * 1. Loads the fixture as its starting document. - * 2. Reads each field's text and each clause's version from the parsed SDTs. - * 3. Compares clause versions against the local library and surfaces a - * Review CTA on every stale clause with a one-line summary of the change. - * 4. Field inputs are reactive: typing in a value debounces by ~250ms and - * fans the new text to every occurrence via `selectByTag` + per-occurrence - * `text.setValue` (the typed API path for plain-text controls). - * 5. Review expands a card showing the in-document clause alongside the - * library version. Replace with library clause swaps body via - * `replaceContent` and bumps the tag version via `patch`. - * 6. Export has two paths: raw DOCX keeps content controls for future - * template/library updates; clean DOCX flattens controls to final values. + * The model: + * - Every control is `contentLocked`, so it can't be edited by typing in the + * document. This is a locked template surface; the custom UI drives changes. + * - Template tab = the building-block library. Two catalogs: smart-field chips + * (reusable variables) and clause cards (governed sections, single-use). A + * chip drags/clicks in as an inline field. A clause card shows category / + * jurisdiction / version and a status: "Add clause" when available (drag or + * click to add) or "In contract" once placed (click reveals the existing one + * - a clause appears once, like an inclusion checklist). An unfilled field + * shows its field-name token (e.g. DISCLOSING_PARTY) as a stand-in + * placeholder - literal text content, not a native SDT placeholder. + * - A clause is assembled from structured `parts`: prose plus `{ field }` + * slots. Inserting it creates the block and wraps each slot as a nested, + * locked inline smart field - so an inserted "Permitted Use" carries real + * Receiving party / Purpose fields, like the seeded one. + * - Values tab = fill the fields. Editing a value debounces ~250ms and fans it + * to every occurrence, including ones nested in clauses (the write briefly + * unlocks clauses, since a clause's content lock otherwise vetoes nested + * writes), via `selectByTag` + `text.setValue`. + * - Export: raw DOCX keeps the controls/tags; clean DOCX flattens to values. * - * Every mutation goes through `editor.doc.*`. The same operation set runs - * headless via the Node SDK and CLI. + * Out of scope (deliberately): clause version review / replace. That's a clause + * lifecycle demo; this one proves template assembly. * - * For a packaged React authoring component (`{{` trigger, linked field - * groups, owner/signer types, DOCX export), see `@superdoc-dev/template-builder`. + * Every mutation goes through `editor.doc.*`, so the same operations run headless + * via the Node SDK and CLI. For a packaged React authoring component, see + * `@superdoc-dev/template-builder`. */ import { SuperDoc } from 'superdoc'; import { createSuperDocUI } from 'superdoc/ui'; import 'superdoc/style.css'; import './style.css'; -import { attachFieldChip } from './field-chip.js'; type NodeKind = 'block' | 'inline'; type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; type ContentControlTarget = { kind: NodeKind; nodeType: 'sdt'; nodeId: string }; +// Minimal shapes for inserting at the caret (see `editor.doc.create.contentControl`). +type SelectionPoint = { kind: 'text'; blockId: string; offset: number }; +type SelectionTarget = { kind: 'selection'; start: SelectionPoint; end: SelectionPoint }; type ContentControlInfo = { target: ContentControlTarget; @@ -60,10 +68,22 @@ type MutationResult = | { success: false; failure: { code: string; message: string } }; type DocumentApi = { + create: { + contentControl(input: { + kind: NodeKind; + controlType?: 'text' | 'richText'; + at?: SelectionTarget; + content?: string; + tag?: string; + alias?: string; + lockMode?: LockMode; + }): MutationResult; + }; contentControls: { list(input?: Record): { items: ContentControlInfo[]; total: number }; selectByTag(input: { tag: string }): { items: ContentControlInfo[]; total: number }; patch(input: { target: ContentControlTarget; tag?: string; alias?: string }): MutationResult; + setLockMode(input: { target: ContentControlTarget; lockMode: LockMode }): MutationResult; replaceContent(input: { target: ContentControlTarget; content: string; format?: 'text' }): MutationResult; text: { setValue(input: { target: ContentControlTarget; value: string }): MutationResult; @@ -94,76 +114,119 @@ type ClauseId = | 'permittedUse' | 'termination' | 'governingLaw' - | 'limitationOfLiability'; + | 'limitationOfLiability' + | 'indemnification' + | 'returnOfMaterials'; + +type ClauseCategory = 'Core' | 'Confidentiality' | 'Termination' | 'Risk Allocation'; -type PreviewSegment = { kind: 'same' | 'insert' | 'delete'; text: string }; +/** + * A clause-body part: literal prose, or a `{ field }` slot that becomes a nested + * inline smart-field SDT when the clause is inserted. The slot renders as the + * field's current display (value if filled, otherwise its name token). + */ +type ClausePart = string | { field: FieldKey }; +/** + * A governed clause in the library catalog: a label + metadata for the sidebar + * card, and the structured `parts` used to assemble the clause when it's + * inserted. The catalog includes clauses that aren't in the document yet. + */ type LibraryClause = { id: ClauseId; label: string; - latestVersion: string; - /** Upgrade prose. Only defined when `latestVersion` differs from v1. */ - upgrade?: { - version: string; - summary: string; - body: string; - /** Hand-authored proposed-change view shown in the review panel. */ - preview: PreviewSegment[]; - }; + category: ClauseCategory; + jurisdiction: string; + version: string; + parts: ClausePart[]; }; const CLAUSE_LIBRARY: LibraryClause[] = [ - { id: 'preamble', label: 'Preamble', latestVersion: 'v1' }, + { + id: 'preamble', + label: 'Preamble', + category: 'Core', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'The parties wish to share Confidential Information for the purposes described above and acknowledge the obligations set out in this Agreement.', + ], + }, { id: 'confidentiality', label: 'Confidentiality Obligations', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Extends survival period from 2 years to 5 years.', - body: 'Each party will treat the other party\u2019s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for five (5) years.', - preview: [ - { kind: 'same', text: 'Each party will treat the other party\u2019s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for ' }, - { kind: 'delete', text: 'two (2) years' }, - { kind: 'insert', text: 'five (5) years' }, - { kind: 'same', text: '.' }, - ], - }, + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Each party will treat the other party’s Confidential Information as confidential and will protect it with at least the same care it uses for its own confidential information. These obligations survive disclosure for two (2) years.', + ], + }, + { + id: 'permittedUse', + label: 'Permitted Use', + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + // Carries nested smart fields: inserting this clause creates real inline + // SDTs for Receiving party and Purpose that fill from the Values form. + parts: [ + 'The ', + { field: 'receivingParty' }, + ' may use Confidential Information solely for ', + { field: 'purpose' }, + ', and for no other purpose, and will limit access to its employees and advisors with a need to know.', + ], + }, + { + id: 'termination', + label: 'Term and Termination', + category: 'Termination', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Either party may terminate this Agreement upon thirty (30) days’ written notice. Confidentiality obligations survive termination for the period specified above.', + ], }, - { id: 'permittedUse', label: 'Permitted Use', latestVersion: 'v1' }, - { id: 'termination', label: 'Term and Termination', latestVersion: 'v1' }, { id: 'governingLaw', label: 'Governing Law', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Changes governing law from California to New York.', - body: 'This Agreement is governed by the laws of the State of New York, without regard to its conflicts of law provisions.', - preview: [ - { kind: 'same', text: 'This Agreement is governed by the laws of the State of ' }, - { kind: 'delete', text: 'California' }, - { kind: 'insert', text: 'New York' }, - { kind: 'same', text: ', without regard to its conflicts of law provisions.' }, - ], - }, + category: 'Core', + jurisdiction: 'US-CA', + version: 'v1', + parts: ['This Agreement is governed by the laws of the State of California, without regard to its conflicts of law provisions.'], }, { id: 'limitationOfLiability', label: 'Limitation of Liability', - latestVersion: 'v2', - upgrade: { - version: 'v2', - summary: 'Extends liability cap from 12 to 24 months and excludes confidentiality and indemnity obligations.', - body: 'Each party\u2019s aggregate liability under this Agreement is limited to fees paid in the twenty-four (24) months preceding the claim. Confidentiality breaches and indemnity obligations are excluded from this cap.', - preview: [ - { kind: 'same', text: 'Each party\u2019s aggregate liability under this Agreement is limited to fees paid in the ' }, - { kind: 'delete', text: 'twelve (12)' }, - { kind: 'insert', text: 'twenty-four (24)' }, - { kind: 'same', text: ' months preceding the claim.' }, - { kind: 'insert', text: ' Confidentiality breaches and indemnity obligations are excluded from this cap.' }, - ], - }, + category: 'Risk Allocation', + jurisdiction: 'General', + version: 'v1', + parts: ['Each party’s aggregate liability under this Agreement is limited to fees paid in the twelve (12) months preceding the claim.'], + }, + { + // A library-only clause: not in the seeded document, so it starts "Add clause". + // Insert it to add a new governed section to the contract. + id: 'indemnification', + label: 'Indemnification', + category: 'Risk Allocation', + jurisdiction: 'General', + version: 'v1', + parts: ['Each party will indemnify and hold the other harmless from third-party claims arising out of its breach of this Agreement.'], + }, + { + // Library-only and carries a nested field slot: adding it shows that an + // inserted clause's embedded variables become real, broadcast-linked SDTs. + id: 'returnOfMaterials', + label: 'Return of Materials', + category: 'Confidentiality', + jurisdiction: 'General', + version: 'v1', + parts: [ + 'Upon termination or at the disclosing party’s request, ', + { field: 'receivingParty' }, + ' will promptly return or destroy all Confidential Information in its possession.', + ], }, ]; @@ -176,8 +239,10 @@ type ReusableSectionTag = { kind: 'reusableSection'; sectionId: ClauseId; versio type TagPayload = SmartFieldTag | ReusableSectionTag; const fieldTag = (key: FieldKey) => JSON.stringify({ kind: 'smartField', key } satisfies SmartFieldTag); -const clauseTag = (sectionId: ClauseId, version: string) => - JSON.stringify({ kind: 'reusableSection', sectionId, version } satisfies ReusableSectionTag); +// version is vestigial now (the version lifecycle was removed); inserted clauses +// carry v1 so the tag shape stays valid and parses as a reusableSection. +const clauseTag = (sectionId: ClauseId) => + JSON.stringify({ kind: 'reusableSection', sectionId, version: 'v1' } satisfies ReusableSectionTag); const parseTag = (tag: string | undefined): TagPayload | null => { if (!tag) return null; @@ -197,18 +262,30 @@ const parseTag = (tag: string | undefined): TagPayload | null => { const state = { editor: null as DemoEditor | null, values: {} as Record, - versions: {} as Record, - expandedClause: null as ClauseId | null, + /** Smart-tag chip mirrored as active when the caret is in a matching field. */ + activeTagKey: null as FieldKey | null, + /** Clause card mirrored as active when the caret is in a matching clause. */ + activeClauseId: null as ClauseId | null, /** UI controller; created in `initialize`, disposed by `teardown`. */ ui: null as ReturnType | null, - /** Field-chip detach handle; created in `initialize`, called by `teardown`. */ - fieldChipTeardown: null as (() => void) | null, + /** Detaches the document -> palette highlight listeners. */ + smartTagSyncTeardown: null as (() => void) | null, + /** Detaches the field drag-and-drop listeners on the editor host. */ + dragDropTeardown: null as (() => void) | null, }; +/** dataTransfer MIME used when dragging a field chip from the palette. */ +const FIELD_MIME = 'application/x-superdoc-field'; +/** dataTransfer MIME used when dragging a clause card from the palette. */ +const CLAUSE_MIME = 'application/x-superdoc-clause'; + const statusEl = qs('#status'); const summaryEl = qs('#summary'); const fieldsPanelEl = qs('#fields-panel'); -const clausesPanelEl = qs('#clauses-panel'); +const valuesPanelEl = qs('#values-panel'); +// The clause cards live in a section inside the Template panel; this container +// is created by renderClausesSection() and re-rendered by renderClausesPanel(). +let clausesListEl: HTMLElement | null = null; setBusy(true); @@ -217,10 +294,16 @@ const superdoc = new SuperDoc({ documentMode: 'editing', document: '/nda-template.docx', // Disable SuperDoc's built-in SDT chrome (border, label, hover/selection - // highlight). The wrappers and data-sdt-* datasets are preserved, so the - // contextual field chip (field-chip.ts) and the document API still work; - // this demo paints its own SDT visuals in style.css instead. - modules: { comments: false, contentControls: { chrome: 'none' } }, + // highlight). The wrappers and data-sdt-* datasets are preserved, so the demo + // paints its own field look in style.css and drives its own UI (Smart-tags + // palette, Locate/Focus) through the public surface. + modules: { + comments: false, + contentControls: { chrome: 'none' }, + // responsiveToContainer collapses toolbar items that overflow the editor + // column into an overflow menu, so the toolbar can't spill over the sidebar. + toolbar: { selector: '#superdoc-toolbar', responsiveToContainer: true }, + }, telemetry: { enabled: false }, onReady: ({ superdoc: sd }) => void initialize(sd as DemoSuperDoc), }); @@ -263,87 +346,348 @@ async function initialize(instance: DemoSuperDoc): Promise { return; } state.editor = instance.activeEditor; - readStateFromDocument(); + // Show each field's name as a placeholder and lock it; values are filled only + // through the Values form, which starts empty (see showFieldNamesAndLock). + showFieldNamesAndLock(); + // Lock the seeded clause blocks too, so their prose can't be edited by typing + // in the document. Fields nested in them still fill through the Values form. + lockClauses(); renderPanels(); refreshSummary(); - // Contextual smart-field chip (SD-3157). Plain TS — uses the - // public `superdoc/ui` controller directly, no framework. The chip - // anchors over the active smart-field SDT and shows the field's - // label + current value tracked in `state.values`. See - // `field-chip.ts` for the scope cut and follow-up notes. - // - // Both the UI controller and the chip teardown are stashed on - // `state` so the module-level `teardown()` handler can dispose them - // on page unload / Vite HMR. Without that, every hot reload would - // leave the previous controller's scroll/resize listeners attached - // to `window` and the previous chip element in the DOM. + // The public `superdoc/ui` controller (no framework) backs the demo's UI: + // the Smart-tags palette (insert/focus), Locate, and the document -> palette + // highlight below. Stashed on `state` so `teardown()` can dispose it on page + // unload / Vite HMR (otherwise each hot reload leaks the previous controller). state.ui = createSuperDocUI({ superdoc: instance }); - // Active control comes from the SuperDoc event (SD-3232); placement from - // the UI controller's getRect (SD-3157). - state.fieldChipTeardown = attachFieldChip(instance, state.ui, { - labelFor: (key) => FIELDS.find((f) => f.key === (key as FieldKey))?.label ?? key, - valueFor: (key) => state.values[key as FieldKey], - }); + + // Document -> palette: clicking a smart-field token in the editor highlights + // its chip in the sidebar (dogfoods content-control:click). Cleared on blur. + const onTokenClick = ({ target }: { target: { tag?: string } }) => { + const parsed = target?.tag ? parseTag(target.tag) : null; + state.activeTagKey = parsed?.kind === 'smartField' ? (parsed.key as FieldKey) : null; + state.activeClauseId = parsed?.kind === 'reusableSection' ? (parsed.sectionId as ClauseId) : null; + highlightActiveTag(); + highlightActiveClause(); + }; + const onActiveChange = ({ active }: { active: { tag?: string } | null }) => { + if (active) return; + state.activeTagKey = null; + state.activeClauseId = null; + highlightActiveTag(); + highlightActiveClause(); + }; + instance.on('content-control:click', onTokenClick); + instance.on('content-control:active-change', onActiveChange); + state.smartTagSyncTeardown = () => { + instance.off('content-control:click', onTokenClick); + instance.off('content-control:active-change', onActiveChange); + }; + + // Palette -> document: drag a field or clause onto the editor to insert it at + // the drop point (dogfoods ui.viewport.positionAt + create.contentControl). + state.dragDropTeardown = setupPaletteDragDrop(); setStatus('Ready'); setBusy(false); } -/** Read field values and clause versions from the loaded fixture. */ -function readStateFromDocument(): void { - const doc = getDoc(); - for (const ctrl of doc.contentControls.list({}).items) { - const tag = parseTag(ctrl.properties?.tag); - if (!tag) continue; - if (tag.kind === 'smartField') { - state.values[tag.key] = ctrl.text ?? ''; - } else if (tag.kind === 'reusableSection') { - state.versions[tag.sectionId] = tag.version; - } - } -} // --------------------------------------------------------------------------- // Mutations: smart fields, clause updates, export // --------------------------------------------------------------------------- -/** Push a single field's value to every occurrence in the document. */ +/** + * Push a field's value to every occurrence. The field controls are + * `contentLocked` so a user can't type into them in the document; the Values + * form is the only writer. `text.setValue` is itself blocked on a locked + * control, so briefly unlock, write, then relock. The relock is in `finally` + * so a failed write never strands a field unlocked (editable by the user). + */ function applyField(key: FieldKey, value: string): void { - if (!state.editor?.doc) return; + const doc = state.editor?.doc; + if (!doc) return; state.values[key] = value; - const { items } = state.editor.doc.contentControls.selectByTag({ tag: fieldTag(key) }); - for (const ctrl of items) { - state.editor.doc.contentControls.text.setValue({ target: ctrl.target, value }); + // Filled -> the value; cleared -> back to the field-name placeholder. + const display = fieldDisplay(key); + + // A field can sit inside a clause (Receiving party / Purpose appear inside the + // Permitted Use clause). A clause's content lock SILENTLY vetoes writes to + // anything nested in it - text.setValue even reports success - so the value + // wouldn't broadcast to the nested occurrence. Briefly unlock every clause + // around the write, then relock them in `finally` so they never stay unlocked. + const clauseControls = () => + doc.contentControls.list({}).items.filter((c) => parseTag(c.properties?.tag)?.kind === 'reusableSection'); + for (const c of clauseControls()) { + reportMutation(doc.contentControls.setLockMode({ target: c.target, lockMode: 'unlocked' }), 'Unlock clause'); + } + try { + for (const ctrl of doc.contentControls.selectByTag({ tag: fieldTag(key) }).items) { + // Skip the write if unlock fails - the field stays locked (safe), just stale. + if (!reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'unlocked' }), `Unlock ${key}`)) { + continue; + } + try { + reportMutation(doc.contentControls.text.setValue({ target: ctrl.target, value: display }), `Update ${key}`); + } finally { + // A failed relock would leave the field editable, so make it loud. + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), `Relock ${key}`); + } + } + } finally { + for (const c of clauseControls()) { + reportMutation(doc.contentControls.setLockMode({ target: c.target, lockMode: 'contentLocked' }), 'Relock clause'); + } + } +} + +/** + * Put the document into its starting template state. Each smart field's content + * is set to its field-name token (e.g. DISCLOSING_PARTY) as a stand-in + * placeholder - this is literal text content, NOT a native SDT placeholder + * (those are renderer-hardcoded and not settable via the API). Then each field + * is `contentLocked`, so values change only through the Values form, never by + * typing in the document. The form starts empty (every field unfilled). Content + * is written before locking, since a locked control rejects content writes. + */ +function showFieldNamesAndLock(): void { + const doc = state.editor?.doc; + if (!doc) return; + for (const field of FIELDS) { + state.values[field.key] = ''; + for (const ctrl of doc.contentControls.selectByTag({ tag: fieldTag(field.key) }).items) { + reportMutation(doc.contentControls.text.setValue({ target: ctrl.target, value: fieldDisplay(field.key) }), `Reset ${field.key}`); + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), `Lock ${field.key}`); + } + } +} + +/** + * Lock every clause block as `contentLocked`, like the inline fields, so its + * prose can't be edited by typing in the document. The clauses are a fixed, + * read-only part of the loaded template. + */ +function lockClauses(): void { + const doc = state.editor?.doc; + if (!doc) return; + for (const ctrl of doc.contentControls.list({}).items) { + if (parseTag(ctrl.properties?.tag)?.kind === 'reusableSection') { + reportMutation(doc.contentControls.setLockMode({ target: ctrl.target, lockMode: 'contentLocked' }), 'Lock clause'); + } + } +} + +/** The token text shown inside an unfilled field (e.g. `disclosingParty` -> `DISCLOSING_PARTY`). */ +const tokenFor = (key: FieldKey): string => key.replace(/([A-Z])/g, '_$1').toUpperCase(); + +/** + * What a field control should display: the entered value if the field is filled, + * otherwise its field-name token (e.g. `DISCLOSING_PARTY`) as a visible + * placeholder. The Values form is the source of truth for filled/unfilled. + */ +const fieldDisplay = (key: FieldKey): string => { + const value = state.values[key] ?? ''; + return value.trim() ? value : tokenFor(key); +}; + +/** + * Insert a smart-tag field as an inline SDT at `target` (a collapsed + * SelectionTarget). The control shows the field name as its placeholder + * (`fieldDisplay`, e.g. DISCLOSING_PARTY) and is `contentLocked`, so it behaves + * like the seeded fields: filled only through the Values form. It's tagged so it + * paints with the same `.superdoc-structured-content-inline` look as the palette + * chips. Shared by click-to-insert (caret) and drag-and-drop (drop point); only + * how `target` is resolved differs. Then scroll it into view so the user sees it. + */ +function insertField(key: FieldKey, label: string, target: SelectionTarget): void { + const ui = state.ui; + const editor = state.editor; + if (!ui || !editor?.doc) return; + const result = editor.doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: target, + content: fieldDisplay(key), + tag: fieldTag(key), + alias: label, + lockMode: 'contentLocked', + }); + if (result.success) { + state.values[key] = state.values[key] ?? ''; + void ui.contentControls.scrollIntoView({ id: result.contentControl.nodeId, block: 'center' }); } } -async function applyClauseVersion(clauseId: ClauseId, toVersion: string, body: string): Promise { - const doc = getDoc(); +/** + * Insert a field at the caret (click-to-insert). Captures the caret as a + * TextTarget and bridges it to a collapsed SelectionTarget (the verified recipe). + */ +function insertFieldAtCursor(key: FieldKey, label: string): void { + const ui = state.ui; + if (!ui || !state.editor?.doc) return; + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) { + // No caret to insert at — tell the user instead of silently no-op'ing. + setStatus('Place the cursor in the document (or drag the field in), then click a tag to insert it.'); + return; + } + const point: SelectionPoint = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; + insertField(key, label, { kind: 'selection', start: point, end: point }); +} + +/** The clause's plain text; each field slot renders as its current display. */ +function clauseText(clause: LibraryClause): string { + return clause.parts.map((part) => (typeof part === 'string' ? part : fieldDisplay(part.field))).join(''); +} + +/** Character ranges of each field slot within `clauseText`, for wrapping as SDTs. */ +function clauseFieldRanges(clause: LibraryClause): { field: FieldKey; start: number; end: number }[] { + const ranges: { field: FieldKey; start: number; end: number }[] = []; + let offset = 0; + for (const part of clause.parts) { + const text = typeof part === 'string' ? part : fieldDisplay(part.field); + if (typeof part !== 'string') ranges.push({ field: part.field, start: offset, end: offset + text.length }); + offset += text.length; + } + return ranges; +} + +/** + * Insert a governed clause as a locked block SDT at the START of `blockId` + * (offset 0, a clean block boundary - inserting at the raw drop caret would + * split a paragraph mid-text). The clause is assembled from its parts: the block + * holds the prose, and each `{ field }` slot is wrapped as a nested, locked + * inline smart-field SDT - so an inserted "Permitted Use" carries real Receiving + * party / Purpose fields that fill from the Values form, like the seeded one. + * Inserts unlocked, wraps the slots, then locks the clause. + */ +async function insertClause(clauseId: ClauseId, blockId: string): Promise { + const ui = state.ui; + const editor = state.editor; + if (!ui || !editor?.doc) return; + const doc = editor.doc; const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); if (!clause) return; - const ctrl = findClauseControl(clauseId); - if (!ctrl) throw new Error(`Clause ${clauseId} not in document`); + const point: SelectionPoint = { kind: 'text', blockId, offset: 0 }; + const created = doc.create.contentControl({ + kind: 'block', + controlType: 'richText', + at: { kind: 'selection', start: point, end: point }, + content: clauseText(clause), + tag: clauseTag(clauseId), + alias: clause.label, + lockMode: 'unlocked', // unlocked so the field slots can be wrapped, then locked + }); + if (!reportMutation(created, `Insert ${clause.label}`) || !created.success) return; + const clauseTarget = created.contentControl; + + // Wrap each field slot as a nested inline smart-field SDT. Focus the new block + // to resolve its inner text blockId (no coordinates needed), then wrap by + // character range - last slot first, so wrapping one can't shift another's + // offsets. + await ui.contentControls.focus({ id: clauseTarget.nodeId }); + const innerBlockId = ui.selection.capture()?.target?.segments?.[0]?.blockId; + if (innerBlockId) { + for (const range of [...clauseFieldRanges(clause)].reverse()) { + reportMutation( + doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: { + kind: 'selection', + start: { kind: 'text', blockId: innerBlockId, offset: range.start }, + end: { kind: 'text', blockId: innerBlockId, offset: range.end }, + }, + tag: fieldTag(range.field), + alias: FIELDS.find((f) => f.key === range.field)?.label ?? range.field, + lockMode: 'contentLocked', + }), + `Nest ${range.field}`, + ); + } + } + + // Lock the clause now that its slots are wrapped, then refresh the cards + // (the card flips to "In contract") and scroll the new clause into view. + reportMutation(doc.contentControls.setLockMode({ target: clauseTarget, lockMode: 'contentLocked' }), 'Lock clause'); + renderClausesPanel(); + void ui.contentControls.scrollIntoView({ id: clauseTarget.nodeId, block: 'center' }); +} - assertMutation( - doc.contentControls.replaceContent({ target: ctrl.target, content: body, format: 'text' }), - `Could not update ${clause.label}`, - true, - ); +/** Insert a clause at the caret's block boundary (click-to-insert). */ +function insertClauseAtCursor(clauseId: ClauseId): void { + const ui = state.ui; + if (!ui || !state.editor?.doc) return; + // Single-use: if it's already in the contract, reveal it instead of duplicating. + if (isClauseInDocument(clauseId)) { + revealClause(clauseId); + return; + } + const seg = ui.selection.capture()?.target?.segments?.[0]; + if (!seg) { + setStatus('Place the cursor in the document, then click a clause to add it.'); + return; + } + void insertClause(clauseId, seg.blockId); +} + +/** + * Palette -> document drag-and-drop for both building blocks. Resolves the drop + * point with the public `ui.viewport.positionAt`, then: a field inserts inline + * at the exact caret; a clause inserts as a block at the drop block's boundary + * (see insertClause). Returns a teardown. + */ +function setupPaletteDragDrop(): () => void { + const host = qs('#editor'); + const draggingPaletteItem = (event: DragEvent) => + event.dataTransfer?.types.includes(FIELD_MIME) || event.dataTransfer?.types.includes(CLAUSE_MIME); - const refreshed = findClauseControl(clauseId) ?? ctrl; - assertMutation( - doc.contentControls.patch({ - target: refreshed.target, - tag: clauseTag(clauseId, toVersion), - alias: `${clause.label} (${toVersion})`, - }), - `Could not patch clause tag for ${clause.label}`, - true, - ); + const onDragOver = (event: DragEvent): void => { + if (!draggingPaletteItem(event)) return; + // preventDefault on dragover is what makes an element a valid drop target. + event.preventDefault(); + event.dataTransfer!.dropEffect = 'copy'; + host.classList.add('drop-target'); + }; + const onDragLeave = (event: DragEvent): void => { + // Only clear when leaving the host itself, not when crossing child nodes. + if (event.target === host) host.classList.remove('drop-target'); + }; + const onDrop = (event: DragEvent): void => { + host.classList.remove('drop-target'); + const fieldKey = event.dataTransfer?.getData(FIELD_MIME) as FieldKey | ''; + const clauseId = event.dataTransfer?.getData(CLAUSE_MIME) as ClauseId | ''; + if (!fieldKey && !clauseId) return; + event.preventDefault(); + const hit = state.ui?.viewport.positionAt({ x: event.clientX, y: event.clientY }); + // A text caret is the only droppable target (a node-edge hit has no offset). + if (!hit || hit.point.kind !== 'text') { + setStatus('Drop onto the document text.'); + return; + } + if (fieldKey) { + const field = FIELDS.find((f) => f.key === fieldKey); + if (!field) return; + const point: SelectionPoint = { kind: 'text', blockId: hit.point.blockId, offset: hit.point.offset }; + insertField(field.key, field.label, { kind: 'selection', start: point, end: point }); + } else if (clauseId) { + const clause = CLAUSE_LIBRARY.find((c) => c.id === clauseId); + if (!clause) return; + // Single-use: a clause already in the contract reveals instead of duplicating. + if (isClauseInDocument(clause.id)) revealClause(clause.id); + else void insertClause(clause.id, hit.point.blockId); // offset 0 (block boundary) + } + }; - state.versions[clauseId] = toVersion; + host.addEventListener('dragover', onDragOver); + host.addEventListener('dragleave', onDragLeave); + host.addEventListener('drop', onDrop); + return () => { + host.removeEventListener('dragover', onDragOver); + host.removeEventListener('dragleave', onDragLeave); + host.removeEventListener('drop', onDrop); + }; } async function exportDocument(mode: 'raw' | 'clean'): Promise { @@ -393,11 +737,128 @@ function focusByTag(tag: string): void { function renderPanels(): void { renderFieldsPanel(); - renderClausesPanel(); + renderValuesPanel(); } +/** + * Smart-tags palette: a searchable list of variable chips. Clicking a chip + * inserts that field as an inline SDT at the caret (authoring). The chips use + * the same token look (.smart-tag / --tag-*) as the painted in-editor field, so + * the sidebar tag and the inserted field are visually identical. + */ +function renderSmartTagsPalette(): void { + const section = document.createElement('div'); + section.className = 'smart-tags'; + section.innerHTML = ` +

Drag a field into the document, or click to insert it at the cursor.

+ +
Template fields
+
+ ${FIELDS.map( + (f) => + ``, + ).join('')} +
+ `; + fieldsPanelEl.appendChild(section); + + section.querySelectorAll('.smart-tag').forEach((btn) => { + const field = FIELDS.find((f) => f.key === (btn.dataset.tagKey as FieldKey)); + btn.addEventListener('click', () => { + if (field) insertFieldAtCursor(field.key, field.label); + }); + btn.addEventListener('dragstart', (event) => { + if (!field || !event.dataTransfer) return; + event.dataTransfer.setData(FIELD_MIME, field.key); + event.dataTransfer.effectAllowed = 'copy'; + }); + }); + + const search = section.querySelector('.smart-tags-search'); + search?.addEventListener('input', () => { + const q = search.value.trim().toUpperCase(); + section.querySelectorAll('.smart-tag').forEach((btn) => { + btn.style.display = !q || (btn.textContent ?? '').includes(q) ? '' : 'none'; + }); + }); + + highlightActiveTag(); +} + +/** + * Mirror the active field in the palette: the chip whose key matches + * `state.activeTagKey` gets `.is-active`. Driven by `content-control:click` + * (and cleared on blur via `content-control:active-change`) — the document -> + * sidebar half of the two-way loop. + */ +function highlightActiveTag(): void { + fieldsPanelEl.querySelectorAll('.smart-tag').forEach((btn) => { + btn.classList.toggle('is-active', btn.dataset.tagKey === state.activeTagKey); + }); +} + +/** + * Mirror the active clause: the card whose id matches `state.activeClauseId` + * gets `.is-active`. Driven by `content-control:click` on a clause block (and + * cleared on blur) — the clauses' half of the document -> sidebar loop. + */ +function highlightActiveClause(): void { + clausesListEl?.querySelectorAll('.clause').forEach((card) => { + card.classList.toggle('is-active', card.dataset.clauseId === state.activeClauseId); + }); +} + +/** + * Template tab: the contract's building blocks. Two catalogs - inline Smart tags + * (reusable variable chips, drag or click to insert) and block Clauses (governed, + * single-use cards with metadata + a status; an available clause adds by drag or + * click, one already in the contract reveals it). Values are filled on the + * Values tab. + */ function renderFieldsPanel(): void { fieldsPanelEl.innerHTML = ''; + renderSmartTagsPalette(); + renderClausesSection(); +} + +/** + * Clauses section of the Template tab: a search + the clause cards. Mirrors the + * Smart-tags section's style (group header, search) but the clauses render as + * compact blue cards, not pills, since they're block controls. Creates the + * list container (clausesListEl) that renderClausesPanel re-renders into. + */ +function renderClausesSection(): void { + const section = document.createElement('div'); + section.className = 'clauses-section'; + section.innerHTML = ` +
Clauses
+

Drag a clause into the document, or click to insert it at the cursor.

+ +
+ `; + fieldsPanelEl.appendChild(section); + clausesListEl = section.querySelector('.clauses-list'); + + const search = section.querySelector('.clauses-search'); + search?.addEventListener('input', () => { + const q = search.value.trim().toLowerCase(); + clausesListEl?.querySelectorAll('.clause').forEach((card) => { + const label = (card.querySelector('.clause-label')?.textContent ?? '').toLowerCase(); + card.style.display = !q || label.includes(q) ? '' : 'none'; + }); + }); + + renderClausesPanel(); +} + +/** + * Values tab: fill the fields that are in the document. Editing a value + * debounces ~250ms and fans it to every occurrence of that field's tag + * (`selectByTag` + per-occurrence `text.setValue`). Locate/Focus jump to the + * first occurrence. + */ +function renderValuesPanel(): void { + valuesPanelEl.innerHTML = ''; for (const field of FIELDS) { // A
wrapper (not
- + `; - fieldsPanelEl.appendChild(row); + valuesPanelEl.appendChild(row); row.querySelector('.locate')?.addEventListener('click', () => { locateByTag(fieldTag(field.key)); }); @@ -437,97 +898,82 @@ function renderFieldsPanel(): void { } } +/** + * Render the clause cards: one card per clause, styled like the in-document + * block clause (blue left rail). Like the smart-tag chips, a card is draggable + * into the document or click-to-insert at the cursor (insertClause snaps to a + * block boundary). A card highlights when its clause is clicked in the document. + */ +/** Every control in the document for a given clause (used internally for counts). */ +function clauseControls(clauseId: ClauseId): ContentControlInfo[] { + const doc = state.editor?.doc; + if (!doc) return []; + return doc.contentControls.list({}).items.filter((c) => { + const t = parseTag(c.properties?.tag); + return t?.kind === 'reusableSection' && t.sectionId === clauseId; + }); +} + +/** A clause is single-use: it's either in the contract or available to add. */ +function isClauseInDocument(clauseId: ClauseId): boolean { + return clauseControls(clauseId).length > 0; +} + +/** Scroll the clause's placement into view and highlight its card. */ +function revealClause(clauseId: ClauseId): void { + const ctrl = clauseControls(clauseId)[0]; + if (!state.ui || !ctrl) return; + state.activeClauseId = clauseId; + highlightActiveClause(); + void state.ui.contentControls.scrollIntoView({ id: ctrl.target.nodeId, block: 'center' }); +} + +/** + * Render the clause library as a single-use inclusion checklist. Each card shows + * the clause's category / jurisdiction / version and whether it's "In contract" + * or available to "Add clause". A clause is governed and appears once: a card + * that's already in the contract can't be inserted again - clicking it reveals + * the existing clause instead; an available card inserts (click) or drags in. + */ function renderClausesPanel(): void { - clausesPanelEl.innerHTML = ''; + const list = clausesListEl; + if (!list) return; + list.innerHTML = ''; for (const clause of CLAUSE_LIBRARY) { - const inDoc = state.versions[clause.id] ?? clause.latestVersion; - const stale = clause.upgrade != null && inDoc !== clause.latestVersion; - const expanded = stale && state.expandedClause === clause.id; - + const inDoc = isClauseInDocument(clause.id); const card = document.createElement('article'); - card.className = 'clause' + (stale ? ' stale' : ' current') + (expanded ? ' expanded' : ''); - - if (stale && clause.upgrade) { - const upgrade = clause.upgrade; - const previewHtml = upgrade.preview.map(renderSegment).join(''); - card.innerHTML = ` -
-

${escapeHtml(clause.label)}

-
- Update available - - -
-
-

${escapeHtml(upgrade.summary)}

-

Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}

- - ${ - expanded - ? ` -
-
Proposed change
-

${previewHtml}

- -
- ` - : '' - } - `; - card.querySelector('.clause-review')?.addEventListener('click', () => { - state.expandedClause = expanded ? null : clause.id; - renderClausesPanel(); - }); - card.querySelector('.clause-replace')?.addEventListener('click', () => { - void run(`${clause.label}: replaced with library clause`, async () => { - await applyClauseVersion(clause.id, upgrade.version, upgrade.body); - state.expandedClause = null; - }); - }); - } else { - card.innerHTML = ` -
-

${escapeHtml(clause.label)}

-
- Current - - -
-
-

Document ${escapeHtml(inDoc)}

- `; - } - - card.querySelector('.locate')?.addEventListener('click', () => { - locateByTag(clauseTag(clause.id, inDoc)); - }); - card.querySelector('.focus')?.addEventListener('click', () => { - focusByTag(clauseTag(clause.id, inDoc)); + card.className = + 'clause ' + (inDoc ? 'is-present' : 'is-available') + (clause.id === state.activeClauseId ? ' is-active' : ''); + card.dataset.clauseId = clause.id; + card.draggable = !inDoc; // single-use: can't drag a clause that's already in + card.title = inDoc + ? `${clause.label} is in the contract — click to reveal it` + : `Drag into the document, or click to add the ${clause.label} clause at the cursor`; + card.innerHTML = ` +
+

${escapeHtml(clause.label)}

+ ${inDoc ? 'In contract' : 'Add clause'} +
+

${escapeHtml(clause.category)} · ${escapeHtml(clause.jurisdiction)} · ${escapeHtml(clause.version)}

+ `; + card.addEventListener('click', () => (isClauseInDocument(clause.id) ? revealClause(clause.id) : insertClauseAtCursor(clause.id))); + card.addEventListener('dragstart', (event) => { + if (!event.dataTransfer || isClauseInDocument(clause.id)) return; + event.dataTransfer.setData(CLAUSE_MIME, clause.id); + event.dataTransfer.effectAllowed = 'copy'; }); - clausesPanelEl.appendChild(card); + list.appendChild(card); } } function refreshSummary(): void { - const stale = CLAUSE_LIBRARY.filter( - (c) => c.upgrade != null && (state.versions[c.id] ?? c.latestVersion) !== c.latestVersion, - ).length; - const updateText = stale === 0 ? 'all clauses current' : `${stale} update${stale === 1 ? '' : 's'} available`; - summaryEl.textContent = `${FIELDS.length} fields \u00b7 ${CLAUSE_LIBRARY.length} clauses \u00b7 ${updateText}`; + summaryEl.textContent = `${FIELDS.length} fields \u00b7 ${CLAUSE_LIBRARY.length} clauses`; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function findClauseControl(clauseId: ClauseId): ContentControlInfo | undefined { - const doc = getDoc(); - return doc.contentControls.list({}).items.find((ctrl) => { - const t = parseTag(ctrl.properties?.tag); - return t?.kind === 'reusableSection' && t.sectionId === clauseId; - }); -} - async function run(status: string, action: () => Promise): Promise { setBusy(true); setStatus('Working'); @@ -548,10 +994,18 @@ function getDoc(): DocumentApi { return state.editor.doc; } -function assertMutation(result: MutationResult, message: string, allowNoOp = false): void { - if (result.success) return; - if (allowNoOp && result.failure.code === 'NO_OP') return; - throw new Error(result.failure.message || message); +/** + * Surface a failed mutation instead of swallowing it. Returns whether it + * succeeded so callers can branch (e.g. skip the write if the unlock failed). + * NO_OP (value already matches) is treated as success. Used on the form-only + * write path, where a silent failure would leave a field stale or - worse, on a + * failed relock - editable by the user. + */ +function reportMutation(result: MutationResult, context: string): boolean { + if (result.success || result.failure.code === 'NO_OP') return true; + console.error(`[contract-templates] ${context} failed:`, result.failure); + setStatus(`${context} failed: ${result.failure.message}`); + return false; } function setBusy(busy: boolean): void { @@ -574,13 +1028,6 @@ function escapeHtml(s: string): string { return s.replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[ch]!); } -function renderSegment(seg: PreviewSegment): string { - const text = escapeHtml(seg.text); - if (seg.kind === 'insert') return `${text}`; - if (seg.kind === 'delete') return `${text}`; - return text; -} - function escapeAttr(s: string): string { return escapeHtml(s).replace(/'/g, '''); } @@ -592,16 +1039,21 @@ function escapeAttr(s: string): string { }; const teardown = () => { - // Order matters: detach the field chip first (it relies on the UI - // controller for `getRect`), then destroy the UI controller, then - // the SuperDoc instance. Each step is best-effort so a late error in - // one branch doesn't strand the others. + // Detach the palette-sync listeners, then destroy the UI controller, then the + // SuperDoc instance. Each step is best-effort so a late error in one branch + // doesn't strand the others. + try { + state.smartTagSyncTeardown?.(); + } catch { + /* best-effort teardown */ + } + state.smartTagSyncTeardown = null; try { - state.fieldChipTeardown?.(); + state.dragDropTeardown?.(); } catch { /* best-effort teardown */ } - state.fieldChipTeardown = null; + state.dragDropTeardown = null; try { state.ui?.destroy(); } catch { diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index 9d7213e80b..ae499069a1 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -34,7 +34,30 @@ button { cursor: pointer; } .app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; } .editor-area { display: flex; flex-direction: column; min-width: 0; min-height: 0; } -.editor-area #editor { flex: 1; overflow: auto; padding: 12px; } +/* SuperDoc's formatting toolbar, rendered into #superdoc-toolbar. Clip to the + editor column so overflowing items can't paint over the sidebar (they collapse + into the responsive overflow menu instead; popovers append to ). */ +#superdoc-toolbar { + border-bottom: 1px solid var(--demo-border); + background: var(--demo-bg); + overflow: hidden; + min-width: 0; +} +/* Center the page horizontally; align to top so multi-page docs scroll. */ +.editor-area #editor { + flex: 1; + overflow: auto; + padding: 12px; + display: flex; + justify-content: center; + align-items: flex-start; +} +/* Drop zone: highlight the editor while a field chip is dragged over it. */ +.editor-area #editor.drop-target { + outline: 2px dashed var(--demo-accent); + outline-offset: -4px; + background: var(--demo-accent-soft); +} .toolbar { display: flex; @@ -136,9 +159,9 @@ input:focus { } .btn:disabled { color: var(--demo-text-muted); cursor: not-allowed; opacity: 0.55; } -/* "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; } +/* Field rows (Values tab): "Locate" scrolls a control into view + (ui.contentControls.scrollIntoView); "Focus" scrolls AND places the caret + inside it (ui.contentControls.focus). */ .row-actions { display: flex; align-items: center; gap: 6px; } .locate, .focus { @@ -161,96 +184,71 @@ input:focus { #fields-panel .btn { width: 100%; margin-top: 12px; } /* ----------------------------------------------------------------------- - Clauses panel + Clause library (Template tab) + + A single-use catalog of governed clauses. Each card echoes the in-document + block clause (blue left rail, soft border, faint blue fill) and shows its + metadata plus a status: an available clause ("Add clause") drags or clicks in, + while one already in the contract ("In contract") is a quieter card that + clicks to reveal the existing section. The clauses themselves are locked blocks. ----------------------------------------------------------------------- */ +.clauses-section { margin-top: 16px; } + .clause { - border: 1px solid var(--demo-border); - border-radius: var(--sd-radius-100, 6px); - padding: 10px 12px; - margin-bottom: 8px; - background: var(--demo-bg); -} -.clause.current { - background: transparent; - border-color: var(--demo-border); -} -.clause.stale { - background: var(--demo-stale-soft); - border-color: var(--demo-accent); -} -.clause-header { + border: 1px solid var(--tag-block-border); + border-left: 4px solid var(--tag-color); + border-radius: var(--tag-radius); + background-color: var(--tag-block-bg); + padding: 7px 10px; + margin-bottom: 6px; +} +.clause:hover { background-color: var(--tag-block-bg-hover); } +.clause.is-active { box-shadow: 0 0 0 2px var(--tag-color); } +/* Available clauses drag/click to add; clauses already in the contract are a + quieter card you click to reveal the existing section. */ +.clause.is-available { cursor: grab; } +.clause.is-available:active { cursor: grabbing; } +.clause.is-present { cursor: pointer; } +.clause.is-present { background-color: var(--tag-block-bg); } +.clause-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; - margin-bottom: 4px; } .clause-label { margin: 0; + flex: 1; + min-width: 0; font-size: var(--sd-font-size-300, 13px); font-weight: 600; color: var(--demo-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .clause-status { - font-size: var(--sd-font-size-200, 11px); + flex-shrink: 0; + font-size: var(--sd-font-size-100, 10px); font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--demo-accent); -} -.clause-status.muted { color: var(--demo-text-muted); } -.clause-summary { - margin: 0 0 6px; - font-size: var(--sd-font-size-300, 13px); - color: var(--demo-text); - line-height: 1.4; -} -.clause-meta { - margin: 0 0 8px; - font-size: var(--sd-font-size-200, 12px); - color: var(--demo-text-muted); -} -.clause .btn { width: 100%; } -.clause-review-panel { - margin-top: 10px; - padding-top: 10px; - border-top: 1px solid var(--demo-accent); - display: flex; - flex-direction: column; - gap: 10px; + letter-spacing: 0.04em; + padding: 1px 6px; + border-radius: 999px; } -.review-label { - font-size: var(--sd-font-size-200, 11px); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; +.clause.is-present .clause-status { color: var(--demo-text-muted); + background: color-mix(in srgb, var(--demo-text-muted) 14%, transparent); } -.clause-preview { - margin: 0; - padding: 10px 12px; - background: var(--demo-bg); - border: 1px solid var(--demo-border); - border-radius: var(--sd-radius-50, 4px); - font-size: var(--sd-font-size-300, 13px); - line-height: 1.5; - color: var(--demo-text); -} -.clause-preview ins { - background: color-mix(in srgb, #15803d 18%, transparent); - color: var(--demo-text); - text-decoration: none; - padding: 0 2px; - border-radius: 2px; +.clause.is-available .clause-status { + color: var(--tag-fg); + background: var(--tag-bg); } -.clause-preview del { +.clause-meta { + margin: 2px 0 0; + font-size: var(--sd-font-size-100, 11px); color: var(--demo-text-muted); - text-decoration: line-through; - text-decoration-color: #d92e2e; - background: color-mix(in srgb, #d92e2e 12%, transparent); - padding: 0 2px; - border-radius: 2px; } .status { @@ -273,52 +271,116 @@ input:focus { exists under chrome-none, so there is nothing to style for it. ----------------------------------------------------------------------- */ -/* Inline smart fields: blue pill */ +/* Smart-tag token look, shared by the in-editor inline SDT and the Smart-tags + palette chips so a sidebar tag and the field it inserts read as one object. */ +:root { + /* Template fields use the SuperDoc brand blue (brand.md: blue-500 / blue-600), + via the --sd-color-blue-* tokens. One token set, applied to both the palette + chip and the painted in-editor field. Fields read as tinted/outlined pills, + so they stay distinct from the solid-blue primary buttons. */ + --tag-color: var(--sd-color-blue-500, #1355ff); + --tag-border: var(--tag-color); + --tag-bg: color-mix(in srgb, var(--tag-color) 12%, transparent); + --tag-fg: var(--sd-color-blue-600, #0f44cc); + --tag-bg-hover: color-mix(in srgb, var(--tag-color) 22%, transparent); + /* Block clauses are large regions, so they wear the same blue quietly: + a left rail + a faint fill + a soft border, lighter than the inline pill. */ + --tag-block-border: color-mix(in srgb, var(--tag-color) 30%, var(--demo-border)); + --tag-block-bg: color-mix(in srgb, var(--tag-color) 4%, var(--demo-bg)); + --tag-block-bg-hover: color-mix(in srgb, var(--tag-color) 8%, var(--demo-bg)); + --tag-radius: 6px; +} +/* Inline smart fields: token pill (painted SDT wrapper under chrome:'none'). + The box (border width + padding) is identical in every state so clicking / + hovering a field never shifts layout. Hover changes only the fill. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] { - padding: 1px 4px; - border: 1px solid var(--demo-accent); - border-radius: 4px; - background-color: var(--demo-accent-soft); + padding: 1px 6px; + border: 1px solid var(--tag-border); + border-radius: var(--tag-radius); + background-color: var(--tag-bg); + color: var(--tag-fg); +} +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField']:hover { + /* Under chrome:'none' SuperDoc resets the field's border + fill (including on + hover) so the consumer owns the look. We re-assert the box so hover + never moves or recolors the field. The !important wins over that reset + without coupling to SuperDoc's selector specificity — a custom-UI styling + rough edge today (no first-class per-control styling hook yet). */ + border: 1px solid var(--tag-border) !important; + background-color: var(--tag-bg-hover) !important; +} +/* Selecting a field is a ProseMirror NodeSelection (.ProseMirror-selectednode). + Under chrome:'none' SuperDoc resets the border + fill to transparent in that + state too; without re-asserting, the field loses its fill and the box can + shift (~2px) on click. Keep the same box and a controlled blue "selected" + fill so hover/click/selected stay on-brand and never move the field. */ +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'].ProseMirror-selectednode { + /* !important to win over the chrome-none reset; same rough edge as hover. */ + border: 1px solid var(--tag-border) !important; + background-color: var(--tag-bg-hover) !important; + color: var(--tag-fg) !important; } -/* Block clauses: bordered card with soft background */ -.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { +/* Smart-tags palette (sidebar). Chips reuse the --tag-* token look above, so a + palette chip and the field it inserts are visually identical. */ +.smart-tags { margin-bottom: 14px; } +.smart-tags-hint { margin: 0 0 8px; font-size: var(--sd-font-size-200, 12px); color: var(--demo-text-muted); } +.smart-tags-search { + width: 100%; + height: 30px; + padding: 0 10px; + margin-bottom: 10px; border: 1px solid var(--demo-border); - border-radius: 4px; - background-color: var(--demo-bg); + border-radius: var(--sd-radius-50, 4px); + background: var(--demo-bg); + color: var(--demo-text); } - -/* - * Contextual smart-field chip (SD-3157 / SD-3232). Floats over the active - * smart-field SDT showing field label + live value. Wired in field-chip.ts - * against the public `content-control:active-change` event (what's active) - * + `ui.contentControls.getRect` (where to draw). With built-in chrome off - * (SD-3159), the chip is the smart field's active-state affordance: custom - * UI anchored to the SDT via public events and the geometry API. - */ -.sd-field-chip { - display: inline-flex; - align-items: center; - padding: 3px 8px; - border-radius: 999px; - font-size: 11px; +.smart-tags-group { + font-size: var(--sd-font-size-100, 11px); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--demo-text-muted); + margin-bottom: 6px; +} +.smart-tags-list { display: flex; flex-wrap: wrap; gap: 6px; } +.smart-tag { + font: inherit; + /* Match the in-editor field pill (which inherits the ~11pt document font) so + a chip and the token it inserts read as the same size, not just color. */ + font-size: 14px; font-weight: 500; - background: var(--demo-bg, #ffffff); - border: 1px solid var(--demo-accent, #2563eb); - color: var(--demo-accent, #2563eb); - box-shadow: 0 2px 6px rgba(15, 23, 42, 0.08); - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.2; + cursor: grab; + padding: 1px 6px; + border: 1px solid var(--tag-border); + border-radius: var(--tag-radius); + background: var(--tag-bg); + color: var(--tag-fg); white-space: nowrap; - max-width: 320px; - overflow: hidden; - text-overflow: ellipsis; } +.smart-tag:hover { background: var(--tag-bg-hover); } +.smart-tag:active { cursor: grabbing; } +.smart-tag.is-active { box-shadow: 0 0 0 2px var(--demo-accent); } +.smart-tag:focus-visible { outline: 1px solid var(--demo-accent); outline-offset: 1px; } -.sd-field-chip__label { - font-weight: 600; -} - -.sd-field-chip__value { - color: var(--demo-text, #18181b); - font-weight: 400; +/* Block clauses: a quiet card with a blue left rail, same field language as + the inline pills, but a region not a token: soft border, faint fill, a 4px + blue spine. */ +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { + border: 1px solid var(--tag-block-border); + border-left: 4px solid var(--tag-color); + border-radius: var(--tag-radius); + background-color: var(--tag-block-bg); +} +/* Under chrome:'none' SuperDoc resets the block's border + fill on hover + (.sdt-group-hover) and select (.ProseMirror-selectednode) — via ::before/ + ::after pseudo-elements, different mechanics than inline. Re-assert the exact + box (no jitter) and lift the fill slightly to show activity. !important wins + over the reset; same custom-UI rough edge as the inline rules above. */ +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'].sdt-group-hover, +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'].ProseMirror-selectednode { + border: 1px solid var(--tag-block-border) !important; + border-left: 4px solid var(--tag-color) !important; + background-color: var(--tag-block-bg-hover) !important; } diff --git a/tests/behavior/tests/sdt/content-control-insert-at-cursor.spec.ts b/tests/behavior/tests/sdt/content-control-insert-at-cursor.spec.ts new file mode 100644 index 0000000000..b04b447097 --- /dev/null +++ b/tests/behavior/tests/sdt/content-control-insert-at-cursor.spec.ts @@ -0,0 +1,57 @@ +/** + * Verifies collapsed-cursor insertion of an inline content control via + * `editor.doc.create.contentControl({ at, content, tag, alias })`. + * + * This is the load-bearing primitive for a "smart tags" authoring UI: clicking + * a tag in a side panel must insert a new inline SDT at the caret. The + * Document API frames `at` as a text range to *wrap*; this confirms a COLLAPSED + * range (a caret, nothing selected) + `content` creates a fresh SDT carrying + * that content. If this fails, it's a real API gap, not a demo problem. + */ + +import { test, expect } from '../../fixtures/superdoc.js'; + +test('@behavior create.contentControl inserts an inline SDT at a collapsed caret', async ({ superdoc }) => { + await superdoc.page.waitForFunction(() => (window as any).editor?.doc?.create?.contentControl, null, { + timeout: 30_000, + }); + + await superdoc.type('Alpha Bravo'); + await superdoc.waitForStable(); + + const result = await superdoc.page.evaluate(() => { + const ed = (window as any).editor; + const ui = (window as any).__bootSuperDocUI?.(); + // Capture the caret (collapsed) and turn it into a SelectionTarget. The UI + // slice exposes the selection as a TextTarget (segments); create.contentControl + // wants a SelectionTarget (start/end points), so bridge the two. + const cap = ui?.selection?.capture?.(); + const seg = cap?.target?.segments?.[0]; + if (!seg) return { ok: false, why: 'no-capture' }; + const point = { kind: 'text', blockId: seg.blockId, offset: seg.range.start }; + const tag = JSON.stringify({ kind: 'smartField', key: 'price' }); + const res = ed.doc.create.contentControl({ + kind: 'inline', + controlType: 'text', + at: { kind: 'selection', start: point, end: point }, + content: 'EXERCISE_PRICE', + tag, + alias: 'Exercise price', + }); + // Read back: is there now an inline structuredContent SDT carrying that tag + text? + let found: { tag: string; text: string } | null = null; + ed.state.doc.descendants((node: any) => { + if (found) return false; + if (node.type.name === 'structuredContent' && node.attrs?.tag === tag) { + found = { tag: node.attrs.tag, text: node.textContent }; + } + return true; + }); + return { ok: true, createSuccess: res?.success === true, failure: res?.failure?.code ?? null, found }; + }); + + expect(result.ok, result.why ?? '').toBe(true); + expect(result.createSuccess, `create.contentControl failed: ${result.failure}`).toBe(true); + expect(result.found, 'inserted inline SDT not found in the document').not.toBeNull(); + expect(result.found!.text).toBe('EXERCISE_PRICE'); +});