Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/docs/solutions/template-builder/api-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ None - the component works with zero configuration.
- `object` — full toolbar configuration (see ToolbarConfig)
</ParamField>

<ParamField path="defaultLockMode" type="'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'">
Lock mode for all inserted fields. Per-field `lockMode` overrides this. See [lock modes](/extensions/structured-content#lock-modes).
</ParamField>

<ParamField path="cspNonce" type="string">
Content Security Policy nonce for dynamically injected styles
</ParamField>
Expand Down Expand Up @@ -174,6 +178,7 @@ interface FieldDefinition {
mode?: "inline" | "block"; // Insertion mode (default: "inline")
group?: string; // Group ID for linked fields
fieldType?: string; // Field type, e.g. "owner" or "signer" (default: "owner")
lockMode?: LockMode; // Lock mode for this field (overrides defaultLockMode)
}
```

Expand All @@ -190,6 +195,7 @@ interface TemplateField {
mode?: "inline" | "block"; // Rendering mode
group?: string; // Group ID for linked fields
fieldType?: string; // Field type, e.g. "owner" or "signer"
lockMode?: LockMode; // Current lock mode of this field
}
```

Expand Down Expand Up @@ -426,6 +432,7 @@ import type {
SuperDocTemplateBuilderHandle,
FieldDefinition,
TemplateField,
LockMode,
TriggerEvent,
ExportEvent,
ExportConfig,
Expand Down
20 changes: 20 additions & 0 deletions apps/docs/solutions/template-builder/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,26 @@ Allow users to create new fields while building templates:

When enabled, the field menu shows a "Create New Field" option with inputs for name, mode (inline/block), and field type (owner/signer).

### Field locking

Make fields read-only so end users can't edit their content:

```tsx
<SuperDocTemplateBuilder
defaultLockMode="contentLocked"
fields={{
available: [
{ id: "1", label: "Customer Name" },
{ id: "2", label: "Notes", lockMode: "unlocked" }, // this one stays editable
],
}}
/>
```

Per-field `lockMode` overrides `defaultLockMode`. The default field creation form includes a "Locked" checkbox that sets `contentLocked`.

Template authors can always delete and manage fields regardless of lock mode — locking only affects end users interacting with the document. For advanced use cases, see [lock modes](/extensions/structured-content#lock-modes).

### Linked fields

When a user selects an existing field from the "Existing Fields" section in the menu, a linked copy is inserted. Both instances share a group ID and stay in sync.
Expand Down
15 changes: 15 additions & 0 deletions packages/template-builder/src/defaults/FieldList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,21 @@ const FieldItem: FC<{
{field.fieldType}
</span>
)}
{field.lockMode && field.lockMode !== 'unlocked' && (
<span
style={{
fontSize: '9px',
padding: '2px 5px',
borderRadius: '3px',
background: '#fef2f2',
color: '#991b1b',
fontWeight: '500',
}}
title={field.lockMode}
>
🔒
</span>
)}
</div>
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions packages/template-builder/src/defaults/FieldMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
const [newFieldName, setNewFieldName] = useState('');
const [fieldMode, setFieldMode] = useState<'inline' | 'block'>('inline');
const [fieldType, setFieldType] = useState<string>('owner');
const [fieldLocked, setFieldLocked] = useState(false);
const [existingExpanded, setExistingExpanded] = useState(true);
const [availableExpanded, setAvailableExpanded] = useState(true);

Expand All @@ -29,6 +30,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
setNewFieldName('');
setFieldMode('inline');
setFieldType('owner');
setFieldLocked(false);
}
}, [isVisible]);

Expand Down Expand Up @@ -69,6 +71,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
label: trimmedName,
mode: fieldMode,
fieldType: fieldType,
...(fieldLocked && { lockMode: 'contentLocked' as const }),
};

try {
Expand All @@ -83,6 +86,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
setNewFieldName('');
setFieldMode('inline');
setFieldType('owner');
setFieldLocked(false);
}
};

Expand Down Expand Up @@ -131,6 +135,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
setIsCreating(false);
setNewFieldName('');
setFieldMode('inline');
setFieldLocked(false);
}
}}
autoFocus
Expand Down Expand Up @@ -227,6 +232,19 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
Signer
</label>
</div>
<div
style={{
marginTop: '8px',
display: 'flex',
gap: '12px',
fontSize: '13px',
}}
>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input type='checkbox' checked={fieldLocked} onChange={(e) => setFieldLocked(e.target.checked)} />
Locked
</label>
</div>
<div
style={{
marginTop: '8px',
Expand Down Expand Up @@ -254,6 +272,7 @@ export const FieldMenu: React.FC<FieldMenuProps> = ({
setNewFieldName('');
setFieldMode('inline');
setFieldType('owner');
setFieldLocked(false);
}}
style={{
padding: '4px 12px',
Expand Down
55 changes: 25 additions & 30 deletions packages/template-builder/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
type Editor = NonNullable<SuperDoc['activeEditor']>;

const getTemplateFieldsFromEditor = (editor: Editor): Types.TemplateField[] => {
const structuredContentHelpers = (editor.helpers as any)?.structuredContentCommands;

Check warning on line 13 in packages/template-builder/src/index.tsx

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type

if (!structuredContentHelpers?.getStructuredContentTags) {
return [];
Expand All @@ -18,7 +18,7 @@

const tags = structuredContentHelpers.getStructuredContentTags(editor.state) || [];

return tags.map((entry: any) => {

Check warning on line 21 in packages/template-builder/src/index.tsx

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
const node = entry?.node ?? entry;
const attrs = node?.attrs ?? {};
const nodeType = node?.type?.name || '';
Expand All @@ -39,6 +39,7 @@
mode,
group: structuredContentHelpers.getGroup?.(attrs.tag) ?? undefined,
fieldType: parsedTag?.fieldType ?? 'owner',
lockMode: attrs.lockMode ?? undefined,
} as Types.TemplateField;
});
};
Expand All @@ -51,6 +52,7 @@
menu = {},
list = {},
toolbar,
defaultLockMode,
cspNonce,
telemetry,
licenseKey,
Expand Down Expand Up @@ -139,22 +141,18 @@
})
: undefined;

const lockMode = field.lockMode ?? defaultLockMode;

const attrs: Record<string, unknown> = {
alias: field.alias,
tag: tagData,
...(lockMode != null && { lockMode }),
};

const success = (
mode === 'inline'
? editor.commands.insertStructuredContentInline?.({
attrs: {
alias: field.alias,
tag: tagData,
},
text: field.defaultValue || field.alias,
})
: editor.commands.insertStructuredContentBlock?.({
attrs: {
alias: field.alias,
tag: tagData,
},
text: field.defaultValue || field.alias,
})
? editor.commands.insertStructuredContentInline?.({ attrs, text: field.defaultValue || field.alias })
: editor.commands.insertStructuredContentBlock?.({ attrs, text: field.defaultValue || field.alias })
) as boolean | undefined;

if (success) {
Expand All @@ -174,7 +172,7 @@

return success ?? false;
},
[onFieldInsert, onFieldsChange, templateFields],
[onFieldInsert, onFieldsChange, templateFields, defaultLockMode],
);

const updateField = useCallback(
Expand Down Expand Up @@ -350,7 +348,7 @@
const editor = instance.activeEditor;
const pe = getPresentationEditor(instance);

editor.on('update', ({ editor: e }: any) => {

Check warning on line 351 in packages/template-builder/src/index.tsx

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
const { state } = e;
const { from } = state.selection;

Expand All @@ -367,7 +365,7 @@
if (!editor) return;
const currentPos = editor.state.selection.from;
const tr = editor.state.tr.delete(triggerStart, currentPos);
(editor as any).view.dispatch(tr);

Check warning on line 368 in packages/template-builder/src/index.tsx

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
};

triggerCleanupRef.current = cleanup;
Expand Down Expand Up @@ -485,6 +483,7 @@
metadata: createdField.metadata,
defaultValue: createdField.defaultValue,
fieldType: createdField.fieldType,
lockMode: createdField.lockMode,
});
setMenuVisible(false);
return;
Expand All @@ -496,6 +495,7 @@
metadata: field.metadata,
defaultValue: field.defaultValue,
fieldType: field.fieldType,
lockMode: field.lockMode,
});
setMenuVisible(false);
},
Expand All @@ -514,7 +514,7 @@
const editor = superdocRef.current?.activeEditor;
if (!editor) return;

const structuredContentHelpers = (editor.helpers as any)?.structuredContentCommands;

Check warning on line 517 in packages/template-builder/src/index.tsx

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type

if (!structuredContentHelpers) return;

Expand All @@ -526,23 +526,18 @@
});

const mode = field.mode || 'inline';
const lockMode = field.lockMode ?? defaultLockMode;

const attrs: Record<string, unknown> = {
alias: field.alias,
tag: tagWithGroup,
...(lockMode != null && { lockMode }),
};

const success =
mode === 'inline'
? editor.commands.insertStructuredContentInline?.({
attrs: {
alias: field.alias,
tag: tagWithGroup,
},
text: field.alias,
})
: editor.commands.insertStructuredContentBlock?.({
attrs: {
alias: field.alias,
tag: tagWithGroup,
},
text: field.alias,
});
? editor.commands.insertStructuredContentInline?.({ attrs, text: field.alias })
: editor.commands.insertStructuredContentBlock?.({ attrs, text: field.alias });

if (success) {
if (!field.group) {
Expand All @@ -556,7 +551,7 @@
onFieldsChange?.(updatedFields);
}
},
[updateField, resetMenuFilter, onFieldsChange],
[updateField, resetMenuFilter, onFieldsChange, defaultLockMode],
);

const handleMenuClose = useCallback(() => {
Expand Down
12 changes: 12 additions & 0 deletions packages/template-builder/src/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,18 @@ describe('areTemplateFieldsEqual', () => {
const b: TemplateField[] = [{ id: '1', alias: 'Name', fieldType: 'signer' }];
expect(areTemplateFieldsEqual(a, b)).toBe(false);
});

it('returns false when lockMode differs', () => {
const a: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'unlocked' }];
const b: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'sdtContentLocked' }];
expect(areTemplateFieldsEqual(a, b)).toBe(false);
});

it('returns true when lockMode is the same', () => {
const a: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'contentLocked' }];
const b: TemplateField[] = [{ id: '1', alias: 'Name', lockMode: 'contentLocked' }];
expect(areTemplateFieldsEqual(a, b)).toBe(true);
});
});

describe('resolveToolbar', () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/template-builder/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import type { SuperDoc } from 'superdoc';

export type LockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';

/** Field definition for template builder */
export interface FieldDefinition {
id: string;
label: string;
defaultValue?: string;
metadata?: Record<string, any>;

Check warning on line 10 in packages/template-builder/src/types.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
mode?: 'inline' | 'block';
group?: string;
fieldType?: string;
lockMode?: LockMode;
}

/** Field instance in a template document */
Expand All @@ -20,6 +23,7 @@
mode?: 'inline' | 'block';
group?: string;
fieldType?: string;
lockMode?: LockMode;
}

export interface TriggerEvent {
Expand Down Expand Up @@ -86,7 +90,7 @@
responsiveToContainer?: boolean;
excludeItems?: string[];
texts?: Record<string, string>;
icons?: Record<string, any>;

Check warning on line 93 in packages/template-builder/src/types.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
}

/**
Expand Down Expand Up @@ -114,11 +118,14 @@
list?: ListConfig;
toolbar?: boolean | string | ToolbarConfig;

/** Lock mode applied to all inserted fields unless overridden per-field */
defaultLockMode?: LockMode;

/** Content Security Policy nonce for dynamically injected styles */
cspNonce?: string;

/** Telemetry configuration for SuperDoc */
telemetry?: { enabled: boolean; metadata?: Record<string, any> };

Check warning on line 128 in packages/template-builder/src/types.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type

/** License key for SuperDoc */
licenseKey?: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/template-builder/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
left.position !== right.position ||
left.mode !== right.mode ||
left.group !== right.group ||
left.fieldType !== right.fieldType
left.fieldType !== right.fieldType ||
left.lockMode !== right.lockMode
) {
return false;
}
Expand Down Expand Up @@ -55,7 +56,7 @@
};

export const getPresentationEditor = (superdoc: SuperDoc | null) => {
const docs = (superdoc as any)?.superdocStore?.documents;

Check warning on line 59 in packages/template-builder/src/utils.ts

View workflow job for this annotation

GitHub Actions / validate

Unexpected any. Specify a different type
if (!Array.isArray(docs) || docs.length === 0) return null;
return docs[0].getPresentationEditor?.() ?? null;
};
Expand Down
Loading