diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 5842f201f4..2f15328dde 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -43,6 +43,10 @@ import 'superdoc/style.css'; import './style.css'; import { attachFieldChip } from './field-chip.js'; +// Lock icons (Font Awesome Free v7.2.0) +const ICON_LOCKED = ``; +const ICON_UNLOCKED = ``; + type NodeKind = 'block' | 'inline'; type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; type ContentControlTarget = { kind: NodeKind; nodeType: 'sdt'; nodeId: string }; @@ -62,8 +66,10 @@ type MutationResult = type DocumentApi = { contentControls: { list(input?: Record): { items: ContentControlInfo[]; total: number }; + getParent(input: { target: ContentControlTarget }): ContentControlInfo | null; 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; @@ -194,10 +200,20 @@ const parseTag = (tag: string | undefined): TagPayload | null => { // State and DOM // --------------------------------------------------------------------------- +/** Child SDT info for nested content controls within a clause. */ +type ChildSdtInfo = { + target: ContentControlTarget; + label: string; + lockMode: LockMode; +}; + const state = { editor: null as DemoEditor | null, values: {} as Record, versions: {} as Record, + lockModes: {} as Record, + /** Nested SDTs within each clause (e.g., inline smart fields inside a block clause). */ + children: {} as Record, expandedClause: null as ClauseId | null, /** UI controller; created in `initialize`, disposed by `teardown`. */ ui: null as ReturnType | null, @@ -284,18 +300,66 @@ async function initialize(instance: DemoSuperDoc): Promise { setBusy(false); } -/** Read field values and clause versions from the loaded fixture. */ +/** Read field values, clause versions, lock modes, and children from the loaded fixture. */ function readStateFromDocument(): void { const doc = getDoc(); - for (const ctrl of doc.contentControls.list({}).items) { + const allControls = doc.contentControls.list({}).items; + + // Build a map of clause nodeIds to their ClauseId for quick lookup + const clauseNodeIdToClauseId = new Map(); + + // First pass: process all SDTs, identify clauses and smart fields + for (const ctrl of allControls) { 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; + state.lockModes[tag.sectionId] = ctrl.lockMode; + state.children[tag.sectionId] = []; // Initialize empty children array + clauseNodeIdToClauseId.set(ctrl.target.nodeId, tag.sectionId); } } + + // Second pass: for each inline SDT, walk up the parent chain to find a clause ancestor + for (const ctrl of allControls) { + if (ctrl.target.kind !== 'inline') continue; // Only check inline SDTs + + // Walk up the parent chain to find a clause + let current: ContentControlInfo | null = ctrl; + let clauseId: ClauseId | undefined; + + while (current) { + const parent = doc.contentControls.getParent({ target: current.target }); + if (!parent) break; + + clauseId = clauseNodeIdToClauseId.get(parent.target.nodeId); + if (clauseId) break; // Found a clause ancestor + + current = parent; // Keep walking up + } + + if (!clauseId) continue; // Not inside any clause + + // This inline SDT is inside a clause - add it as a child + // Use alias from properties, fall back to field label or generic name + const alias = ctrl.properties?.alias; + const tag = parseTag(ctrl.properties?.tag); + const label = + alias || + (tag?.kind === 'smartField' ? FIELDS.find((f) => f.key === tag.key)?.label || tag.key : null) || + ctrl.text || + `Field ${state.children[clauseId].length + 1}`; + + + state.children[clauseId].push({ + target: ctrl.target, + label, + lockMode: ctrl.lockMode, + }); + } + } // --------------------------------------------------------------------------- @@ -340,6 +404,68 @@ async function applyClauseVersion(clauseId: ClauseId, toVersion: string, body: s state.versions[clauseId] = toVersion; } +/** Toggle lockMode between unlocked and contentLocked for a clause (outer SDT). */ +async function toggleClauseLockMode(clauseId: ClauseId): Promise { + const doc = getDoc(); + const ctrl = findClauseControl(clauseId); + if (!ctrl) throw new Error(`Clause ${clauseId} not in document`); + + const currentLock = state.lockModes[clauseId] ?? 'unlocked'; + const newLock: LockMode = currentLock === 'unlocked' ? 'contentLocked' : 'unlocked'; + + assertMutation( + doc.contentControls.setLockMode({ target: ctrl.target, lockMode: newLock }), + `Could not set lock mode for ${clauseId}`, + true, + ); + + state.lockModes[clauseId] = newLock; +} + +/** Toggle lockMode for a child SDT within a clause. */ +async function toggleChildLockMode(clauseId: ClauseId, childIndex: number): Promise { + const doc = getDoc(); + const children = state.children[clauseId]; + if (!children || !children[childIndex]) throw new Error(`Child ${childIndex} not found in ${clauseId}`); + + const child = children[childIndex]; + const newLock: LockMode = child.lockMode === 'unlocked' ? 'contentLocked' : 'unlocked'; + + assertMutation( + doc.contentControls.setLockMode({ target: child.target, lockMode: newLock }), + `Could not set lock mode for child SDT`, + true, + ); + + state.children[clauseId][childIndex].lockMode = newLock; +} + +/** Lock or unlock all SDTs within a clause (the clause itself + all children). */ +async function setAllLockModes(clauseId: ClauseId, lockMode: LockMode): Promise { + const doc = getDoc(); + const ctrl = findClauseControl(clauseId); + if (!ctrl) throw new Error(`Clause ${clauseId} not in document`); + + // Lock the outer clause + assertMutation( + doc.contentControls.setLockMode({ target: ctrl.target, lockMode }), + `Could not set lock mode for ${clauseId}`, + true, + ); + state.lockModes[clauseId] = lockMode; + + // Lock all children + const children = state.children[clauseId] ?? []; + for (let i = 0; i < children.length; i++) { + assertMutation( + doc.contentControls.setLockMode({ target: children[i].target, lockMode }), + `Could not set lock mode for child SDT`, + true, + ); + state.children[clauseId][i].lockMode = lockMode; + } +} + async function exportDocument(mode: 'raw' | 'clean'): Promise { await superdoc.export({ exportedName: mode === 'raw' ? 'Mutual NDA - raw' : 'Mutual NDA - clean', @@ -388,20 +514,65 @@ function renderClausesPanel(): void { const inDoc = state.versions[clause.id] ?? clause.latestVersion; const stale = clause.upgrade != null && inDoc !== clause.latestVersion; const expanded = stale && state.expandedClause === clause.id; + const lockMode = state.lockModes[clause.id] ?? 'unlocked'; + const isLocked = lockMode !== 'unlocked'; + const children = state.children[clause.id] ?? []; + const allLocked = isLocked && children.every((c) => c.lockMode !== 'unlocked'); + const anyLocked = isLocked || children.some((c) => c.lockMode !== 'unlocked'); const card = document.createElement('article'); card.className = 'clause' + (stale ? ' stale' : ' current') + (expanded ? ' expanded' : ''); + // Build the children lock controls HTML (collapsible Fields section) + const childItems = children.map( + (child, i) => { + const isLocked = child.lockMode !== 'unlocked'; + return ` + ${escapeHtml(child.label)} + + ${isLocked ? 'Locked' : 'Unlocked'} + ${isLocked ? ICON_LOCKED : ICON_UNLOCKED} + + `; + }, + ); + + const childrenHtml = + children.length > 0 + ? ` + + ▼ + Field Lock Controls + + ${childItems.join('')} + ` + : ''; + + // Build the lock controls HTML (only show if there are children) + const lockControlsHtml = + children.length > 0 + ? ` + + ${childrenHtml} + + ${allLocked ? 'Unlock All' : 'Lock All'} + ${allLocked ? ICON_LOCKED : ICON_UNLOCKED} + + + ` + : ''; + if (stale && clause.upgrade) { const upgrade = clause.upgrade; const previewHtml = upgrade.preview.map(renderSegment).join(''); card.innerHTML = ` + Update Available ${escapeHtml(clause.label)} - Update available ${escapeHtml(upgrade.summary)} - Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)} + Document ${escapeHtml(inDoc)} · Library ${escapeHtml(upgrade.version)} + ${lockControlsHtml} ${expanded ? 'Hide' : 'Review'} ${ expanded @@ -432,9 +603,37 @@ function renderClausesPanel(): void { Current Document ${escapeHtml(inDoc)} + ${lockControlsHtml} `; } + // Wire up fields toggle (collapse/expand) + card.querySelector('.clause-fields-toggle')?.addEventListener('click', (e) => { + const btn = e.currentTarget as HTMLButtonElement; + const fields = btn.closest('.clause-fields'); + fields?.classList.toggle('collapsed'); + }); + + // Wire up child lock toggles + card.querySelectorAll('.clause-child-lock').forEach((btn) => { + btn.addEventListener('click', () => { + const childIndex = parseInt(btn.dataset.childIndex ?? '0', 10); + const child = children[childIndex]; + const childLocked = child?.lockMode !== 'unlocked'; + void run(`${child?.label ?? 'SDT'}: ${childLocked ? 'unlocked' : 'locked'}`, async () => { + await toggleChildLockMode(clause.id, childIndex); + }); + }); + }); + + // Wire up lock all / unlock all button + card.querySelector('.clause-lock-all')?.addEventListener('click', () => { + const newMode: LockMode = allLocked ? 'unlocked' : 'contentLocked'; + void run(`${clause.label}: ${allLocked ? 'all unlocked' : 'all locked'}`, async () => { + await setAllLockModes(clause.id, newMode); + }); + }); + clausesPanelEl.appendChild(card); } } diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index f47b9ce68e..0cfd6ace1c 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -155,12 +155,12 @@ input:focus { border-color: var(--demo-accent); } .clause-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; + display: block; margin-bottom: 4px; } +.clause-header .clause-status { + margin-bottom: 12px; +} .clause-label { margin: 0; font-size: var(--sd-font-size-300, 13px); @@ -173,6 +173,7 @@ input:focus { text-transform: uppercase; letter-spacing: 0.05em; color: var(--demo-accent); + text-align: left; } .clause-status.muted { color: var(--demo-text-muted); } .clause-summary { @@ -186,7 +187,141 @@ input:focus { font-size: var(--sd-font-size-200, 12px); color: var(--demo-text-muted); } + +/* Lock controls */ +.clause-lock-controls { + margin-bottom: 10px; + padding: 8px; + border: 1px solid var(--demo-border); + border-radius: var(--sd-radius-50, 4px); + background: var(--demo-canvas); +} + +.btn-icon { + width: 28px; + height: 28px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + flex-shrink: 0; +} + +/* Collapsible Fields section */ +.clause-fields { + margin-bottom: 8px; +} + +.clause-fields-toggle { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 4px 0; + background: transparent; + border: none; + cursor: pointer; + text-align: left; +} + +.clause-fields-arrow { + font-size: 10px; + color: var(--demo-text-muted); + transition: transform 0.15s ease; +} + +.clause-fields.collapsed .clause-fields-arrow { + transform: rotate(-90deg); +} + +.clause-fields-title { + font-size: var(--sd-font-size-200, 12px); + font-weight: 600; + color: var(--demo-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.clause-fields-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.clause-fields.collapsed .clause-fields-list { + display: none; +} + +.clause-child { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: var(--demo-bg); + border: 1px solid var(--demo-border); + border-radius: var(--sd-radius-50, 4px); +} + +.clause-child-label { + display: block; + font-size: var(--sd-font-size-300, 13px); + color: var(--demo-text); +} + +.clause-lock-all { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + margin-top: 4px; + padding: 8px 12px; + background: transparent; + border: 1px solid var(--demo-accent); + border-radius: var(--sd-radius-50, 4px); + cursor: pointer; + font: inherit; + color: var(--demo-accent); +} + +.clause-lock-all.is-locked { + background: var(--demo-accent); + color: #fff; +} + .clause .btn { width: 100%; } +.clause-child-lock { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + width: 90px; + padding: 4px 8px; + background: transparent; + border: 1px solid var(--demo-accent); + border-radius: var(--sd-radius-50, 4px); + cursor: pointer; + font: inherit; + color: var(--demo-accent); +} + +.clause-child-lock.is-locked { + background: var(--demo-accent); + color: #fff; +} + +.lock-label { + font-size: var(--sd-font-size-200, 12px); +} + +.lock-icon { + width: 14px; + height: 14px; + fill: currentColor; + vertical-align: middle; +} .clause-review-panel { margin-top: 10px; padding-top: 10px;
${escapeHtml(upgrade.summary)}
Document ${escapeHtml(inDoc)} \u00b7 Library ${escapeHtml(upgrade.version)}
Document ${escapeHtml(inDoc)} · Library ${escapeHtml(upgrade.version)}
Document ${escapeHtml(inDoc)}