From 5090a358f418579e1b51546c8c55ca24e1e6f020 Mon Sep 17 00:00:00 2001 From: aorlov Date: Wed, 18 Feb 2026 21:18:36 +0100 Subject: [PATCH 1/8] feat: implemented signer field type [template builder] - Added field type support in FieldDefinition and TemplateField interfaces. - Introduced field type selection in FieldMenu component with radio buttons for 'owner' and 'signer'. - Updated FieldList and FieldMenu components to display field type badges. - Implemented CSS styles for field types in field-types.css. - Added InfoTooltip component for better user guidance in FieldMenu. - Created utility functions for field comparison and toolbar resolution. - Added tests for new components and functionalities. --- eslint.config.mjs | 2 + packages/template-builder/demo/src/App.css | 8 +- packages/template-builder/demo/src/App.tsx | 9 +- packages/template-builder/package.json | 4 +- .../src/defaults/FieldList.tsx | 14 ++ .../src/defaults/FieldMenu.tsx | 141 +++++++++++++---- .../src/defaults/InfoTooltip.tsx | 61 ++++++++ packages/template-builder/src/index.tsx | 97 +++--------- .../src/styles/field-types.css | 18 +++ .../src/tests/FieldList.test.tsx | 100 ++++++++++++ .../src/tests/FieldMenu.test.tsx | 99 ++++++++++++ .../src/tests/InfoTooltip.test.tsx | 40 +++++ .../template-builder/src/tests/utils.test.ts | 146 ++++++++++++++++++ packages/template-builder/src/types.ts | 2 + packages/template-builder/src/utils.ts | 73 +++++++++ 15 files changed, 709 insertions(+), 105 deletions(-) create mode 100644 packages/template-builder/src/defaults/InfoTooltip.tsx create mode 100644 packages/template-builder/src/styles/field-types.css create mode 100644 packages/template-builder/src/tests/FieldList.test.tsx create mode 100644 packages/template-builder/src/tests/FieldMenu.test.tsx create mode 100644 packages/template-builder/src/tests/InfoTooltip.test.tsx create mode 100644 packages/template-builder/src/tests/utils.test.ts create mode 100644 packages/template-builder/src/utils.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index d12c28d189..f35946d344 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -27,6 +27,8 @@ export default [ '**/src/**/*.d.ts.map', // Test files '**/*.test.js', + '**/*.test.ts', + '**/*.test.tsx', '**/*.spec.js', '**/tests/**', '**/test/**', diff --git a/packages/template-builder/demo/src/App.css b/packages/template-builder/demo/src/App.css index f998dcbb20..c7ad35b39c 100644 --- a/packages/template-builder/demo/src/App.css +++ b/packages/template-builder/demo/src/App.css @@ -252,7 +252,13 @@ header { } .modal-close:hover { - background: #dc2626; + background: #000000; +} + +/* Field type color overrides — just set the CSS variables */ +:root { + --superdoc-field-owner-color: #1bb754; + --superdoc-field-signer-color: #d81313; } /* Responsive */ diff --git a/packages/template-builder/demo/src/App.tsx b/packages/template-builder/demo/src/App.tsx index 68bd9605b1..afe51cb167 100644 --- a/packages/template-builder/demo/src/App.tsx +++ b/packages/template-builder/demo/src/App.tsx @@ -8,16 +8,19 @@ import type { ExportEvent, } from '@superdoc-dev/template-builder'; import 'superdoc/style.css'; +import '@superdoc-dev/template-builder/field-types.css'; import './App.css'; const availableFields: FieldDefinition[] = [ { id: '1242142770', label: 'Agreement Date' }, - { id: '1242142771', label: 'User Name' }, + { id: '1242142771', label: 'User Name', defaultValue: 'John Doe' }, { id: '1242142772', label: 'Company Name' }, { id: '1242142773', label: 'Service Type' }, { id: '1242142774', label: 'Agreement Jurisdiction' }, { id: '1242142775', label: 'Company Address' }, { id: '1242142776', label: 'Signature', mode: 'block' }, + { id: '1242142777', label: 'Signer Name', fieldType: 'signer' }, + { id: '1242142778', label: 'Signer Table', mode: 'block', fieldType: 'signer' }, ]; export function App() { @@ -90,7 +93,9 @@ export function App() { console.log('Fields:', JSON.stringify(event.fields, null, 2)); log(`Exported ${event.fields.length} fields`); event.fields.forEach((f) => { - console.log(` - ${f.alias} (id: ${f.id}, mode: ${f.mode}, group: ${f.group || 'none'})`); + console.log( + ` - ${f.alias} (id: ${f.id}, mode: ${f.mode}, group: ${f.group || 'none'}, fieldType: ${f.fieldType || 'none'})`, + ); }); }, [log], diff --git a/packages/template-builder/package.json b/packages/template-builder/package.json index 7590a75ff0..2636a5b012 100644 --- a/packages/template-builder/package.json +++ b/packages/template-builder/package.json @@ -11,10 +11,12 @@ "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.js" - } + }, + "./field-types.css": "./src/styles/field-types.css" }, "files": [ "dist", + "src/styles", "README.md" ], "scripts": { diff --git a/packages/template-builder/src/defaults/FieldList.tsx b/packages/template-builder/src/defaults/FieldList.tsx index 8db1aa7cf5..89c739f80b 100644 --- a/packages/template-builder/src/defaults/FieldList.tsx +++ b/packages/template-builder/src/defaults/FieldList.tsx @@ -110,6 +110,20 @@ const FieldItem: FC<{ {field.mode} )} + {field.fieldType && ( + + {field.fieldType} + + )} diff --git a/packages/template-builder/src/defaults/FieldMenu.tsx b/packages/template-builder/src/defaults/FieldMenu.tsx index bdb8c2e3f2..dee251ab71 100644 --- a/packages/template-builder/src/defaults/FieldMenu.tsx +++ b/packages/template-builder/src/defaults/FieldMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import type { FieldDefinition, FieldMenuProps } from '../types'; +import { InfoTooltip } from './InfoTooltip'; export const FieldMenu: React.FC = ({ isVisible, @@ -17,6 +18,7 @@ export const FieldMenu: React.FC = ({ const [isCreating, setIsCreating] = useState(false); const [newFieldName, setNewFieldName] = useState(''); const [fieldMode, setFieldMode] = useState<'inline' | 'block'>('inline'); + const [fieldType, setFieldType] = useState('owner'); const [existingExpanded, setExistingExpanded] = useState(true); const [availableExpanded, setAvailableExpanded] = useState(true); @@ -25,6 +27,7 @@ export const FieldMenu: React.FC = ({ setIsCreating(false); setNewFieldName(''); setFieldMode('inline'); + setFieldType('owner'); } }, [isVisible]); @@ -62,6 +65,7 @@ export const FieldMenu: React.FC = ({ id: `custom_${Date.now()}`, label: trimmedName, mode: fieldMode, + fieldType: fieldType, }; try { @@ -75,6 +79,7 @@ export const FieldMenu: React.FC = ({ setIsCreating(false); setNewFieldName(''); setFieldMode('inline'); + setFieldType('owner'); } }; @@ -174,6 +179,47 @@ export const FieldMenu: React.FC = ({ Block +
+ + +
= ({ setIsCreating(false); setNewFieldName(''); setFieldMode('inline'); + setFieldType('owner'); }} style={{ padding: '4px 12px', @@ -263,7 +310,10 @@ export const FieldMenu: React.FC = ({ textAlign: 'left', }} > - Existing Fields ({uniqueEntries.length}) + + Existing Fields ({uniqueEntries.length}) + + = ({ {entry.group ? `group (${entry.count} fields)` : `ID: ${entry.id}`}
- - {entry.mode || 'inline'} - +
+ {entry.fieldType && ( + + {entry.fieldType} + + )} + + {entry.mode || 'inline'} + +
))} @@ -357,7 +423,10 @@ export const FieldMenu: React.FC = ({ textAlign: 'left', }} > - Available Fields ({fieldsToDisplay.length}) + + Available Fields ({fieldsToDisplay.length}) + + = ({ ID: {field.id} - - {field.mode || 'inline'} - +
+ {field.fieldType && ( + + {field.fieldType} + + )} + + {field.mode || 'inline'} + +
))} diff --git a/packages/template-builder/src/defaults/InfoTooltip.tsx b/packages/template-builder/src/defaults/InfoTooltip.tsx new file mode 100644 index 0000000000..3e263a750c --- /dev/null +++ b/packages/template-builder/src/defaults/InfoTooltip.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react'; + +export const InfoTooltip: React.FC<{ text: string }> = ({ text }) => { + const [visible, setVisible] = useState(false); + + return ( + setVisible(true)} + onMouseLeave={() => setVisible(false)} + onClick={(e) => e.stopPropagation()} + > + + + + ? + + + {visible && ( + + {text} + + )} + + ); +}; diff --git a/packages/template-builder/src/index.tsx b/packages/template-builder/src/index.tsx index a9969ccf8c..330b26324d 100644 --- a/packages/template-builder/src/index.tsx +++ b/packages/template-builder/src/index.tsx @@ -2,6 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImper import type { SuperDoc } from 'superdoc'; import type * as Types from './types'; import { FieldMenu, FieldList } from './defaults'; +import { areTemplateFieldsEqual, resolveToolbar, clampToViewport } from './utils'; export * from './types'; export { FieldMenu, FieldList }; @@ -23,87 +24,25 @@ const getTemplateFieldsFromEditor = (editor: Editor): Types.TemplateField[] => { const nodeType = node?.type?.name || ''; const mode = nodeType.includes('Block') ? 'block' : 'inline'; + const parsedTag = (() => { + try { + return typeof attrs.tag === 'string' && attrs.tag.startsWith('{') ? JSON.parse(attrs.tag) : null; + } catch { + return null; + } + })(); + return { id: attrs.id, alias: attrs.alias || attrs.label || '', tag: attrs.tag, mode, group: structuredContentHelpers.getGroup?.(attrs.tag) ?? undefined, + fieldType: parsedTag?.fieldType ?? 'owner', } as Types.TemplateField; }); }; -const areTemplateFieldsEqual = (a: Types.TemplateField[], b: Types.TemplateField[]): boolean => { - if (a === b) return true; - if (a.length !== b.length) return false; - - for (let index = 0; index < a.length; index += 1) { - const left = a[index]; - const right = b[index]; - - if (!right) return false; - - if ( - left.id !== right.id || - left.alias !== right.alias || - left.tag !== right.tag || - left.position !== right.position || - left.mode !== right.mode || - left.group !== right.group - ) { - return false; - } - } - - return true; -}; - -const resolveToolbar = (toolbar: Types.SuperDocTemplateBuilderProps['toolbar']) => { - if (!toolbar) return null; - - if (toolbar === true) { - return { - selector: '#superdoc-toolbar', - config: {} as Omit, - renderDefaultContainer: true, - }; - } - - if (typeof toolbar === 'string') { - return { - selector: toolbar, - config: {} as Omit, - renderDefaultContainer: false, - }; - } - - const { selector, ...config } = toolbar; - return { - selector: selector || '#superdoc-toolbar', - config, - renderDefaultContainer: selector === undefined, - }; -}; - -const MENU_VIEWPORT_PADDING = 10; -const MENU_APPROX_WIDTH = 250; -const MENU_APPROX_HEIGHT = 300; - -const clampToViewport = (rect: DOMRect): DOMRect => { - const maxLeft = window.innerWidth - MENU_APPROX_WIDTH - MENU_VIEWPORT_PADDING; - const maxTop = window.innerHeight - MENU_APPROX_HEIGHT - MENU_VIEWPORT_PADDING; - - const clampedLeft = Math.min(rect.left, maxLeft); - const clampedTop = Math.min(rect.top, maxTop); - - return new DOMRect( - Math.max(clampedLeft, MENU_VIEWPORT_PADDING), - Math.max(clampedTop, MENU_VIEWPORT_PADDING), - rect.width, - rect.height, - ); -}; - const SuperDocTemplateBuilder = forwardRef( (props, ref) => { const { @@ -183,19 +122,27 @@ const SuperDocTemplateBuilder = forwardRef { + it('renders field count in header', () => { + const fields: TemplateField[] = [ + { id: '1', alias: 'Name' }, + { id: '2', alias: 'Email' }, + ]; + render(); + expect(screen.getByText('Template Fields (2)')).toBeInTheDocument(); + }); + + it('shows empty state message when no fields', () => { + render(); + expect(screen.getByText(/No fields yet/)).toBeInTheDocument(); + }); + + it('renders each field with alias', () => { + const fields: TemplateField[] = [ + { id: '1', alias: 'Name' }, + { id: '2', alias: 'Email' }, + ]; + render(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + }); + + it('shows mode badge', () => { + const fields: TemplateField[] = [ + { id: '1', alias: 'Name', mode: 'inline' }, + { id: '2', alias: 'Sig', mode: 'block' }, + ]; + render(); + expect(screen.getByText('inline')).toBeInTheDocument(); + expect(screen.getByText('block')).toBeInTheDocument(); + }); + + it('shows fieldType badge for signer fields', () => { + const fields: TemplateField[] = [{ id: '1', alias: 'Signer Field', fieldType: 'signer' }]; + render(); + expect(screen.getByText('signer')).toBeInTheDocument(); + }); + + it('shows fieldType badge for owner fields', () => { + const fields: TemplateField[] = [{ id: '1', alias: 'Owner Field', fieldType: 'owner' }]; + render(); + expect(screen.getByText('owner')).toBeInTheDocument(); + }); + + it('calls onSelect when clicking a field', () => { + const onSelect = vi.fn(); + const fields: TemplateField[] = [{ id: '1', alias: 'Name' }]; + render(); + fireEvent.click(screen.getByText('Name')); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: '1', alias: 'Name' })); + }); + + it('calls onDelete when clicking delete button', () => { + const onDelete = vi.fn(); + const fields: TemplateField[] = [{ id: '1', alias: 'Name' }]; + render(); + fireEvent.click(screen.getByTitle('Delete field')); + expect(onDelete).toHaveBeenCalledWith('1'); + }); + + it('highlights selected field with different background', () => { + const fields: TemplateField[] = [ + { id: '1', alias: 'Name' }, + { id: '2', alias: 'Email' }, + ]; + render(); + // The FieldItem container with title="Name" has the background style + const nameItem = screen.getByTitle('Name'); + expect(nameItem.getAttribute('style')).toContain('rgb(239, 246, 255)'); + }); + + it('groups fields by group ID with expandable sections', () => { + const fields: TemplateField[] = [ + { id: '1', alias: 'Name', group: 'grp-abc-123456' }, + { id: '2', alias: 'Name', group: 'grp-abc-123456' }, + { id: '3', alias: 'Solo' }, + ]; + render(); + // Group header shows first field alias and count + expect(screen.getByText(/2 fields/)).toBeInTheDocument(); + // Solo field is rendered directly + expect(screen.getByText('Solo')).toBeInTheDocument(); + }); +}); diff --git a/packages/template-builder/src/tests/FieldMenu.test.tsx b/packages/template-builder/src/tests/FieldMenu.test.tsx new file mode 100644 index 0000000000..21145abe3f --- /dev/null +++ b/packages/template-builder/src/tests/FieldMenu.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { FieldMenu } from '../defaults/FieldMenu'; +import type { FieldDefinition, TemplateField } from '../types'; + +afterEach(cleanup); + +const defaultProps = { + isVisible: true, + availableFields: [ + { id: 'name', label: 'Full Name', mode: 'inline' as const }, + { id: 'sig', label: 'Signature', mode: 'block' as const, fieldType: 'signer' }, + ] satisfies FieldDefinition[], + onSelect: vi.fn(), + onClose: vi.fn(), +}; + +describe('FieldMenu', () => { + it('returns null when isVisible is false', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + }); + + it('renders when isVisible is true', () => { + render(); + expect(screen.getByText(/Available Fields/)).toBeInTheDocument(); + }); + + it('shows Available Fields section with field count', () => { + render(); + expect(screen.getByText(/Available Fields \(2\)/)).toBeInTheDocument(); + }); + + it('shows Existing Fields section when existingFields provided', () => { + const existingFields: TemplateField[] = [{ id: 'e1', alias: 'Existing One', mode: 'inline' }]; + render(); + expect(screen.getByText(/Existing Fields \(1\)/)).toBeInTheDocument(); + }); + + it('shows fieldType badge on available fields', () => { + render(); + expect(screen.getByText('signer')).toBeInTheDocument(); + }); + + it('shows fieldType badge on existing fields', () => { + const existingFields: TemplateField[] = [{ id: 'e1', alias: 'Signer Field', fieldType: 'signer' }]; + render(); + const signerBadges = screen.getAllByText('signer'); + expect(signerBadges.length).toBeGreaterThanOrEqual(1); + }); + + it('shows "+ Create New Field" when allowCreate is true', () => { + render(); + expect(screen.getByText('+ Create New Field')).toBeInTheDocument(); + }); + + it('hides create button when allowCreate is false', () => { + render(); + expect(screen.queryByText('+ Create New Field')).toBeNull(); + }); + + it('shows Owner/Signer radio buttons after clicking Create New Field, Owner default', () => { + render(); + fireEvent.click(screen.getByText('+ Create New Field')); + + const ownerRadio = screen.getByDisplayValue('owner') as HTMLInputElement; + const signerRadio = screen.getByDisplayValue('signer') as HTMLInputElement; + expect(ownerRadio.checked).toBe(true); + expect(signerRadio.checked).toBe(false); + }); + + it('calls onSelect when clicking an available field', () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText('Full Name')); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 'name', label: 'Full Name' })); + }); + + it('calls onSelectExisting when clicking an existing field', () => { + const onSelectExisting = vi.fn(); + const existingFields: TemplateField[] = [{ id: 'e1', alias: 'Existing One' }]; + render(); + fireEvent.click(screen.getByText('Existing One')); + expect(onSelectExisting).toHaveBeenCalledWith(expect.objectContaining({ id: 'e1', alias: 'Existing One' })); + }); + + it('calls onClose when clicking Close', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByText('Close')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('shows filter query text', () => { + render(); + expect(screen.getByText('sig')).toBeInTheDocument(); + expect(screen.getByText(/Filtering results for/)).toBeInTheDocument(); + }); +}); diff --git a/packages/template-builder/src/tests/InfoTooltip.test.tsx b/packages/template-builder/src/tests/InfoTooltip.test.tsx new file mode 100644 index 0000000000..124e05dc4d --- /dev/null +++ b/packages/template-builder/src/tests/InfoTooltip.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { InfoTooltip } from '../defaults/InfoTooltip'; + +afterEach(cleanup); + +describe('InfoTooltip', () => { + it('renders the "?" icon', () => { + render(); + expect(screen.getByText('?')).toBeInTheDocument(); + }); + + it('shows tooltip text on mouseEnter', () => { + render(); + const icon = screen.getByText('?').closest('span')!; + fireEvent.mouseEnter(icon); + expect(screen.getByText('Help text here')).toBeInTheDocument(); + }); + + it('hides tooltip on mouseLeave', () => { + render(); + const icon = screen.getByText('?').closest('span')!; + fireEvent.mouseEnter(icon); + expect(screen.getByText('Help text here')).toBeInTheDocument(); + fireEvent.mouseLeave(icon); + expect(screen.queryByText('Help text here')).toBeNull(); + }); + + it('stops propagation on click', () => { + const parentHandler = vi.fn(); + render( +
+ +
, + ); + const icon = screen.getByText('?').closest('span')!; + fireEvent.click(icon); + expect(parentHandler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/template-builder/src/tests/utils.test.ts b/packages/template-builder/src/tests/utils.test.ts new file mode 100644 index 0000000000..5b5e411c10 --- /dev/null +++ b/packages/template-builder/src/tests/utils.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect } from 'vitest'; +import { areTemplateFieldsEqual, resolveToolbar, clampToViewport } from '../utils'; +import type { TemplateField } from '../types'; + +describe('areTemplateFieldsEqual', () => { + it('returns true for reference-equal arrays', () => { + const fields: TemplateField[] = [{ id: '1', alias: 'Name' }]; + expect(areTemplateFieldsEqual(fields, fields)).toBe(true); + }); + + it('returns true for identical field arrays', () => { + const a: TemplateField[] = [ + { id: '1', alias: 'Name', tag: 'tag1', position: 0, mode: 'inline', group: 'g1', fieldType: 'owner' }, + ]; + const b: TemplateField[] = [ + { id: '1', alias: 'Name', tag: 'tag1', position: 0, mode: 'inline', group: 'g1', fieldType: 'owner' }, + ]; + expect(areTemplateFieldsEqual(a, b)).toBe(true); + }); + + it('returns true for empty arrays', () => { + expect(areTemplateFieldsEqual([], [])).toBe(true); + }); + + it('returns false for different lengths', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name' }]; + const b: TemplateField[] = [ + { id: '1', alias: 'Name' }, + { id: '2', alias: 'Email' }, + ]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when id differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name' }]; + const b: TemplateField[] = [{ id: '2', alias: 'Name' }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when alias differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name' }]; + const b: TemplateField[] = [{ id: '1', alias: 'Email' }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when tag differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name', tag: 'a' }]; + const b: TemplateField[] = [{ id: '1', alias: 'Name', tag: 'b' }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when position differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name', position: 0 }]; + const b: TemplateField[] = [{ id: '1', alias: 'Name', position: 5 }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when mode differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name', mode: 'inline' }]; + const b: TemplateField[] = [{ id: '1', alias: 'Name', mode: 'block' }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when group differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name', group: 'g1' }]; + const b: TemplateField[] = [{ id: '1', alias: 'Name', group: 'g2' }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); + + it('returns false when fieldType differs', () => { + const a: TemplateField[] = [{ id: '1', alias: 'Name', fieldType: 'owner' }]; + const b: TemplateField[] = [{ id: '1', alias: 'Name', fieldType: 'signer' }]; + expect(areTemplateFieldsEqual(a, b)).toBe(false); + }); +}); + +describe('resolveToolbar', () => { + it('returns null for falsy input', () => { + expect(resolveToolbar(undefined)).toBeNull(); + expect(resolveToolbar(false)).toBeNull(); + }); + + it('returns default config for true', () => { + const result = resolveToolbar(true); + expect(result).toEqual({ + selector: '#superdoc-toolbar', + config: {}, + renderDefaultContainer: true, + }); + }); + + it('returns custom selector for string input', () => { + const result = resolveToolbar('#my-toolbar'); + expect(result).toEqual({ + selector: '#my-toolbar', + config: {}, + renderDefaultContainer: false, + }); + }); + + it('returns full config for object input', () => { + const result = resolveToolbar({ selector: '#custom', toolbarGroups: ['left'] }); + expect(result).toEqual({ + selector: '#custom', + config: { toolbarGroups: ['left'] }, + renderDefaultContainer: false, + }); + }); + + it('uses default selector when selector is missing in object', () => { + const result = resolveToolbar({ toolbarGroups: ['center'] }); + expect(result).toEqual({ + selector: '#superdoc-toolbar', + config: { toolbarGroups: ['center'] }, + renderDefaultContainer: true, + }); + }); +}); + +describe('clampToViewport', () => { + it('passes through a rect within bounds', () => { + // jsdom defaults: innerWidth=1024, innerHeight=768 + const rect = new DOMRect(100, 100, 0, 0); + const result = clampToViewport(rect); + expect(result.left).toBe(100); + expect(result.top).toBe(100); + expect(result.width).toBe(0); + expect(result.height).toBe(0); + }); + + it('clamps left/top to viewport padding minimum', () => { + const rect = new DOMRect(-50, -50, 0, 0); + const result = clampToViewport(rect); + expect(result.left).toBe(10); // MENU_VIEWPORT_PADDING + expect(result.top).toBe(10); + }); + + it('clamps to max bounds when exceeding viewport', () => { + const rect = new DOMRect(2000, 2000, 0, 0); + const result = clampToViewport(rect); + // maxLeft = 1024 - 250 - 10 = 764 + // maxTop = 768 - 300 - 10 = 458 + expect(result.left).toBe(764); + expect(result.top).toBe(458); + }); +}); diff --git a/packages/template-builder/src/types.ts b/packages/template-builder/src/types.ts index 5554e39b67..2086f5e25b 100644 --- a/packages/template-builder/src/types.ts +++ b/packages/template-builder/src/types.ts @@ -8,6 +8,7 @@ export interface FieldDefinition { metadata?: Record; mode?: 'inline' | 'block'; group?: string; + fieldType?: string; } /** Field instance in a template document */ @@ -18,6 +19,7 @@ export interface TemplateField { position?: number; mode?: 'inline' | 'block'; group?: string; + fieldType?: string; } export interface TriggerEvent { diff --git a/packages/template-builder/src/utils.ts b/packages/template-builder/src/utils.ts new file mode 100644 index 0000000000..c04ce09feb --- /dev/null +++ b/packages/template-builder/src/utils.ts @@ -0,0 +1,73 @@ +import type { TemplateField, SuperDocTemplateBuilderProps, ToolbarConfig } from './types'; + +export const areTemplateFieldsEqual = (a: TemplateField[], b: TemplateField[]): boolean => { + if (a === b) return true; + if (a.length !== b.length) return false; + + for (let index = 0; index < a.length; index += 1) { + const left = a[index]; + const right = b[index]; + + if (!right) return false; + + if ( + left.id !== right.id || + left.alias !== right.alias || + left.tag !== right.tag || + left.position !== right.position || + left.mode !== right.mode || + left.group !== right.group || + left.fieldType !== right.fieldType + ) { + return false; + } + } + + return true; +}; + +export const resolveToolbar = (toolbar: SuperDocTemplateBuilderProps['toolbar']) => { + if (!toolbar) return null; + + if (toolbar === true) { + return { + selector: '#superdoc-toolbar', + config: {} as Omit, + renderDefaultContainer: true, + }; + } + + if (typeof toolbar === 'string') { + return { + selector: toolbar, + config: {} as Omit, + renderDefaultContainer: false, + }; + } + + const { selector, ...config } = toolbar; + return { + selector: selector || '#superdoc-toolbar', + config, + renderDefaultContainer: selector === undefined, + }; +}; + +export const MENU_VIEWPORT_PADDING = 10; +export const MENU_APPROX_WIDTH = 250; +export const MENU_APPROX_HEIGHT = 300; + +export const clampToViewport = (rect: DOMRect): DOMRect => { + const maxLeft = window.innerWidth - MENU_APPROX_WIDTH - MENU_VIEWPORT_PADDING; + const maxTop = window.innerHeight - MENU_APPROX_HEIGHT - MENU_VIEWPORT_PADDING; + + const clampedLeft = Math.min(rect.left, maxLeft); + const clampedTop = Math.min(rect.top, maxTop); + + return new DOMRect( + Math.max(clampedLeft, MENU_VIEWPORT_PADDING), + Math.max(clampedTop, MENU_VIEWPORT_PADDING), + rect.width, + rect.height, + ); +}; From abd149b8dc3d295a229380abd8f12a65e5adf246 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 19 Feb 2026 00:38:42 +0100 Subject: [PATCH 2/8] feat: enhance editor interaction by integrating getPresentationEditor utility --- packages/template-builder/src/index.tsx | 12 +++++++----- packages/template-builder/src/utils.ts | 7 +++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/template-builder/src/index.tsx b/packages/template-builder/src/index.tsx index 330b26324d..8240c8d232 100644 --- a/packages/template-builder/src/index.tsx +++ b/packages/template-builder/src/index.tsx @@ -2,7 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo, forwardRef, useImper import type { SuperDoc } from 'superdoc'; import type * as Types from './types'; import { FieldMenu, FieldList } from './defaults'; -import { areTemplateFieldsEqual, resolveToolbar, clampToViewport } from './utils'; +import { areTemplateFieldsEqual, resolveToolbar, clampToViewport, getPresentationEditor } from './utils'; export * from './types'; export { FieldMenu, FieldList }; @@ -348,8 +348,9 @@ const SuperDocTemplateBuilder = forwardRef { const editor = superdocRef.current?.activeEditor; @@ -395,8 +396,9 @@ const SuperDocTemplateBuilder = forwardRef { @@ -53,6 +54,12 @@ export const resolveToolbar = (toolbar: SuperDocTemplateBuilderProps['toolbar']) }; }; +export const getPresentationEditor = (superdoc: SuperDoc | null) => { + const docs = (superdoc as any)?.superdocStore?.documents; + if (!Array.isArray(docs) || docs.length === 0) return null; + return docs[0].getPresentationEditor?.() ?? null; +}; + export const MENU_VIEWPORT_PADDING = 10; export const MENU_APPROX_WIDTH = 250; export const MENU_APPROX_HEIGHT = 300; From 3bfd8b1babe4c85c2dd12399ec2ea4e788e814f4 Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 19 Feb 2026 01:54:13 +0100 Subject: [PATCH 3/8] refactor: enhance field styling and editor interaction --- packages/template-builder/src/defaults/FieldList.tsx | 4 ++-- packages/template-builder/src/defaults/FieldMenu.tsx | 11 +++++++---- packages/template-builder/src/index.tsx | 3 +-- packages/template-builder/src/utils.ts | 8 ++++++++ 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/template-builder/src/defaults/FieldList.tsx b/packages/template-builder/src/defaults/FieldList.tsx index 89c739f80b..db0621bde6 100644 --- a/packages/template-builder/src/defaults/FieldList.tsx +++ b/packages/template-builder/src/defaults/FieldList.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react'; import { useMemo, useState } from 'react'; import type { FieldListProps, TemplateField } from '../types'; +import { getFieldTypeStyle } from '../utils'; const shortenGroupId = (group: string): string => { const parts = group.split('-'); @@ -116,8 +117,7 @@ const FieldItem: FC<{ fontSize: '9px', padding: '2px 5px', borderRadius: '3px', - background: field.fieldType === 'signer' ? '#fef3c7' : '#f3f4f6', - color: field.fieldType === 'signer' ? '#b45309' : '#4b5563', + ...getFieldTypeStyle(field.fieldType), fontWeight: '500', }} > diff --git a/packages/template-builder/src/defaults/FieldMenu.tsx b/packages/template-builder/src/defaults/FieldMenu.tsx index dee251ab71..4acaf2600b 100644 --- a/packages/template-builder/src/defaults/FieldMenu.tsx +++ b/packages/template-builder/src/defaults/FieldMenu.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import type { FieldDefinition, FieldMenuProps } from '../types'; +import { getFieldTypeStyle } from '../utils'; import { InfoTooltip } from './InfoTooltip'; export const FieldMenu: React.FC = ({ @@ -156,6 +157,7 @@ export const FieldMenu: React.FC = ({ > setFieldMode('inline')} @@ -172,6 +174,7 @@ export const FieldMenu: React.FC = ({ > setFieldMode('block')} @@ -197,6 +200,7 @@ export const FieldMenu: React.FC = ({ > setFieldType('owner')} @@ -213,6 +217,7 @@ export const FieldMenu: React.FC = ({ > setFieldType('signer')} @@ -363,8 +368,7 @@ export const FieldMenu: React.FC = ({ padding: '2px 6px', borderRadius: '3px', textTransform: 'capitalize', - background: entry.fieldType === 'signer' ? '#fef3c7' : '#f3f4f6', - color: entry.fieldType === 'signer' ? '#b45309' : '#6b7280', + ...getFieldTypeStyle(entry.fieldType), fontWeight: 500, }} > @@ -476,8 +480,7 @@ export const FieldMenu: React.FC = ({ padding: '2px 6px', borderRadius: '3px', textTransform: 'capitalize', - background: field.fieldType === 'signer' ? '#fef3c7' : '#f3f4f6', - color: field.fieldType === 'signer' ? '#b45309' : '#6b7280', + ...getFieldTypeStyle(field.fieldType), fontWeight: 500, }} > diff --git a/packages/template-builder/src/index.tsx b/packages/template-builder/src/index.tsx index 8240c8d232..17c848ec2b 100644 --- a/packages/template-builder/src/index.tsx +++ b/packages/template-builder/src/index.tsx @@ -338,6 +338,7 @@ const SuperDocTemplateBuilder = forwardRef { const { state } = e; @@ -348,7 +349,6 @@ const SuperDocTemplateBuilder = forwardRef { return docs[0].getPresentationEditor?.() ?? null; }; +const FIELD_TYPE_STYLES: Record = { + signer: { background: '#fef3c7', color: '#b45309' }, +}; + +const DEFAULT_FIELD_TYPE_STYLE = { background: '#f3f4f6', color: '#6b7280' }; + +export const getFieldTypeStyle = (fieldType: string) => FIELD_TYPE_STYLES[fieldType] ?? DEFAULT_FIELD_TYPE_STYLE; + export const MENU_VIEWPORT_PADDING = 10; export const MENU_APPROX_WIDTH = 250; export const MENU_APPROX_HEIGHT = 300; From 0cc2e77831beaf4e5fd99a77a9011b04266d87ad Mon Sep 17 00:00:00 2001 From: aorlov Date: Thu, 19 Feb 2026 01:57:31 +0100 Subject: [PATCH 4/8] refactor: change position to fixed and improve overflow handling --- packages/template-builder/src/defaults/FieldMenu.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/template-builder/src/defaults/FieldMenu.tsx b/packages/template-builder/src/defaults/FieldMenu.tsx index 4acaf2600b..40e9a9acd5 100644 --- a/packages/template-builder/src/defaults/FieldMenu.tsx +++ b/packages/template-builder/src/defaults/FieldMenu.tsx @@ -34,7 +34,7 @@ export const FieldMenu: React.FC = ({ const menuStyle = useMemo(() => { return { - position: 'absolute' as const, + position: 'fixed' as const, left: position?.left, top: position?.top, zIndex: 1000, @@ -44,6 +44,8 @@ export const FieldMenu: React.FC = ({ boxShadow: '0 2px 8px rgba(0,0,0,0.1)', padding: '8px 0', width: '280px', + maxHeight: `calc(100vh - ${(position?.top ?? 0) + 10}px)`, + overflowY: 'auto' as const, }; }, [position]); @@ -333,7 +335,7 @@ export const FieldMenu: React.FC = ({ /> {existingExpanded && ( -
+
{uniqueEntries.map((entry) => (
= ({ /> {availableExpanded && ( -
+
{fieldsToDisplay.map((field) => (
Date: Thu, 19 Feb 2026 02:15:19 +0100 Subject: [PATCH 5/8] docs: updated template builder documentation and readme file --- apps/docs/scripts/validate-code-imports.ts | 1 + .../template-builder/api-reference.mdx | 208 +++++++-------- .../template-builder/configuration.mdx | 230 ++++++++++------- .../template-builder/introduction.mdx | 16 +- .../solutions/template-builder/quickstart.mdx | 51 +++- packages/template-builder/README.md | 236 +++++++++++------- 6 files changed, 447 insertions(+), 295 deletions(-) diff --git a/apps/docs/scripts/validate-code-imports.ts b/apps/docs/scripts/validate-code-imports.ts index 0e8855199a..6c77be587e 100644 --- a/apps/docs/scripts/validate-code-imports.ts +++ b/apps/docs/scripts/validate-code-imports.ts @@ -29,6 +29,7 @@ const EXACT_SUPERDOC_IMPORTS = new Set([ '@superdoc-dev/react/style.css', '@superdoc-dev/template-builder', '@superdoc-dev/template-builder/defaults', + '@superdoc-dev/template-builder/field-types.css', '@superdoc-dev/superdoc-yjs-collaboration', ]); diff --git a/apps/docs/solutions/template-builder/api-reference.mdx b/apps/docs/solutions/template-builder/api-reference.mdx index 52b82e2ed7..549de199b7 100644 --- a/apps/docs/solutions/template-builder/api-reference.mdx +++ b/apps/docs/solutions/template-builder/api-reference.mdx @@ -37,7 +37,7 @@ None - the component works with zero configuration. Pre-existing fields in the document (auto-discovered if not provided) - Enable users to create new fields on the fly + Show a "Create New Field" option in the menu. The create form lets users pick inline/block mode and owner/signer field type. @@ -46,7 +46,7 @@ None - the component works with zero configuration. Field insertion menu configuration - + Custom menu component @@ -59,18 +59,36 @@ None - the component works with zero configuration. Field list sidebar configuration - + Custom list component - - Sidebar position + + Sidebar position. Omit to hide the sidebar entirely. - - Document editing toolbar. Can be: - `boolean` - show/hide default toolbar - - `string` - space-separated tool names - `object` - full toolbar configuration + + Document editing toolbar. + - `true` — render a default toolbar container + - `string` — CSS selector of an existing element to mount the toolbar into + - `object` — full toolbar configuration (see ToolbarConfig) + + + + Content Security Policy nonce for dynamically injected styles + + + + CSS class name for the root container + + + + Inline styles for the root container + + + + Height of the document editor area @@ -105,7 +123,7 @@ None - the component works with zero configuration. path="onFieldCreate" type="(field: FieldDefinition) => void | Promise" > - Called when user creates a new field (requires `fields.allowCreate = true`) + Called when user creates a new field (requires `fields.allowCreate = true`). Return a modified `FieldDefinition` to override the field before insertion, or `void` to use the field as-is. @@ -132,12 +150,13 @@ Available fields that users can insert: ```typescript interface FieldDefinition { - id: string; // Unique identifier - label: string; // Display name - defaultValue?: string; // Default value for new instances - metadata?: Record; // Custom metadata - mode?: "inline" | "block"; // Field insertion mode (default: "inline") - group?: string; // Category/group name + id: string; // Unique identifier + label: string; // Display name + defaultValue?: string; // Default value for new instances + metadata?: Record; // Custom metadata stored in the SDT tag + 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") } ``` @@ -147,12 +166,13 @@ Fields that exist in the template document: ```typescript interface TemplateField { - id: string | number; // Unique instance ID - alias: string; // Field name/label - tag?: string; // JSON metadata string - position?: number; // Position in document + id: string | number; // Unique instance ID + alias: string; // Field name/label + tag?: string; // JSON metadata string + position?: number; // Position in document mode?: "inline" | "block"; // Rendering mode - group?: string; // Group ID for related fields + group?: string; // Group ID for linked fields + fieldType?: string; // Field type, e.g. "owner" or "signer" } ``` @@ -162,13 +182,9 @@ Information about trigger detection: ```typescript interface TriggerEvent { - query: string; // Text after trigger pattern - position: { - // Cursor position - top: number; - left: number; - }; - mode: "inline" | "block"; // Context mode + position: { from: number; to: number }; // Document position of the trigger + bounds?: DOMRect; // Viewport coordinates for menu positioning + cleanup: () => void; // Removes the trigger text from the document } ``` @@ -179,36 +195,42 @@ Data provided when a template is exported: ```typescript interface ExportEvent { fields: TemplateField[]; // All fields in the template - blob?: Blob; // Document blob (when triggerDownload: false) - fileName: string; // Export filename + blob?: Blob; // Document blob (when triggerDownload: false) + fileName: string; // Export filename } ``` -### MenuProps +### FieldMenuProps Props passed to custom menu components: ```typescript -interface MenuProps { - fields: FieldDefinition[]; // Available fields - onInsert: (field: FieldDefinition) => void; // Insert handler - onClose: () => void; // Close menu handler - position: { top: number; left: number }; // Menu position - query: string; // Current search query - mode: "inline" | "block"; // Insertion mode +interface FieldMenuProps { + isVisible: boolean; // Whether the menu should be shown + position?: DOMRect; // Viewport coordinates for positioning + availableFields: FieldDefinition[]; // All available fields + filteredFields?: FieldDefinition[]; // Fields filtered by the typed query + filterQuery?: string; // Text typed after the trigger pattern + allowCreate?: boolean; // Whether "Create New Field" is enabled + existingFields?: TemplateField[]; // Fields already in the document + onSelect: (field: FieldDefinition) => void; // Insert a new field + onSelectExisting?: (field: TemplateField) => void; // Insert a linked copy + onClose: () => void; // Close the menu + onCreateField?: (field: FieldDefinition) => void | Promise; } ``` -### ListProps +### FieldListProps Props passed to custom list components: ```typescript -interface ListProps { - fields: TemplateField[]; // Fields in template - selectedField: TemplateField | null; // Currently selected field - onFieldSelect: (field: TemplateField) => void; // Selection handler - onFieldDelete: (fieldId: string | number) => void; // Delete handler +interface FieldListProps { + fields: TemplateField[]; // Fields in the template + onSelect: (field: TemplateField) => void; // Select/navigate to a field + onDelete: (fieldId: string | number) => void; // Delete a field + onUpdate?: (field: TemplateField) => void; // Update a field + selectedFieldId?: string | number; // Currently selected field ID } ``` @@ -218,8 +240,8 @@ Configuration for template export: ```typescript interface ExportConfig { - fileName?: string; // Download filename - triggerDownload?: boolean; // Auto-download file + fileName?: string; // Download filename (default: "document") + triggerDownload?: boolean; // Auto-download file (default: true) } ``` @@ -233,12 +255,12 @@ const builderRef = useRef(null); ### insertField() -Insert a field at the current cursor position: +Insert an inline field at the current cursor position: ```typescript builderRef.current?.insertField({ alias: "customer_name", - mode: "inline", + fieldType: "owner", }); ``` @@ -250,8 +272,8 @@ Insert a block-level field: ```typescript builderRef.current?.insertBlockField({ - alias: "terms_section", - mode: "block", + alias: "signature", + fieldType: "signer", }); ``` @@ -264,7 +286,6 @@ Update an existing field: ```typescript builderRef.current?.updateField("field-id", { alias: "new_name", - tag: JSON.stringify({ groupId: "123" }), }); ``` @@ -278,7 +299,7 @@ Remove a field from the template: builderRef.current?.deleteField("field-id"); ``` -Returns `boolean` - true if deleted successfully. +Returns `boolean` - true if deleted successfully. If the deleted field was the last in a group with two members, the remaining field's group tag is automatically removed. ### selectField() @@ -320,7 +341,7 @@ Export the template as a .docx file: ```typescript // Trigger download await builderRef.current?.exportTemplate({ - fileName: "my-template.docx", + fileName: "my-template", triggerDownload: true, }); @@ -328,9 +349,6 @@ await builderRef.current?.exportTemplate({ const blob = await builderRef.current?.exportTemplate({ triggerDownload: false, }); - -// Use blob for API upload or database storage -await uploadToServer(blob); ``` Returns `Promise` depending on `triggerDownload` setting. @@ -342,33 +360,28 @@ Access the underlying SuperDoc editor instance: ```typescript const superdoc = builderRef.current?.getSuperDoc(); -// Use SuperDoc API directly -if (superdoc) { - const editor = superdoc.getEditor(); +if (superdoc?.activeEditor) { // Full access to SuperDoc/SuperEditor APIs + superdoc.activeEditor.commands.search("hello"); } ``` Returns `SuperDoc | null`. -## Default components +## Field type styling -The package exports default UI components you can use as a starting point: +Import the optional CSS to color-code fields by type in the editor: -```typescript -import { - DefaultFieldMenu, - DefaultFieldList, -} from "@superdoc-dev/template-builder/defaults"; - -// Use as-is or extend -function MyCustomMenu(props: MenuProps) { - return ( -
- - -
- ); +```jsx +import "@superdoc-dev/template-builder/field-types.css"; +``` + +Override colors with CSS variables: + +```css +:root { + --superdoc-field-owner-color: #629be7; + --superdoc-field-signer-color: #d97706; } ``` @@ -376,22 +389,19 @@ function MyCustomMenu(props: MenuProps) { Target these classes for custom styling: -| Class | Element | -| ------------------------------- | ------------------------ | -| `.superdoc-template-builder` | Root container | -| `.superdoc-field-menu` | Field insertion popup | -| `.superdoc-field-menu-item` | Individual menu item | -| `.superdoc-field-list` | Sidebar container | -| `.superdoc-field-list-item` | Individual field in list | -| `.superdoc-field-list-group` | Grouped fields container | -| `.superdoc-field-tag` | Field in document | -| `.superdoc-field-tag--selected` | Selected field | -| `.superdoc-field-tag--inline` | Inline field mode | -| `.superdoc-field-tag--block` | Block field mode | +| Class | Element | +| ---------------------------------------- | ------------------------ | +| `.superdoc-template-builder` | Root container | +| `.superdoc-template-builder-sidebar` | Sidebar wrapper | +| `.superdoc-template-builder-document` | Document area wrapper | +| `.superdoc-template-builder-editor` | Editor container | +| `.superdoc-template-builder-toolbar` | Default toolbar container| +| `.superdoc-field-menu` | Field insertion popup | +| `.superdoc-field-list` | Sidebar field list | ## Import types -All TypeScript types are exported for use in your code: +All TypeScript types are exported: ```typescript import type { @@ -401,23 +411,13 @@ import type { TemplateField, TriggerEvent, ExportEvent, - MenuProps, - ListProps, ExportConfig, + FieldMenuProps, + FieldListProps, + ToolbarConfig, + DocumentConfig, + FieldsConfig, + MenuConfig, + ListConfig, } from "@superdoc-dev/template-builder"; ``` - -## Keyboard shortcuts - -Built-in keyboard navigation: - -| Shortcut | Action | -| -------------- | ------------------------------- | -| `Tab` | Jump to next field | -| `Shift + Tab` | Jump to previous field | -| `{{` (default) | Open field menu | -| `Esc` | Close field menu | -| `Enter` | Insert selected field from menu | -| `↑` / `↓` | Navigate menu items | - -The trigger pattern is configurable via the `menu.trigger` prop. diff --git a/apps/docs/solutions/template-builder/configuration.mdx b/apps/docs/solutions/template-builder/configuration.mdx index 01625993c8..0773b217a8 100644 --- a/apps/docs/solutions/template-builder/configuration.mdx +++ b/apps/docs/solutions/template-builder/configuration.mdx @@ -18,8 +18,8 @@ Control which document is loaded and how users interact with it: /> ``` -**Editing mode** - Users can edit document content and insert fields -**Viewing mode** - Read-only document display, fields can still be inserted +**Editing mode** - Users can edit document content and insert fields. +**Viewing mode** - Read-only document display, fields can still be inserted. ## Field system @@ -34,21 +34,54 @@ Define which fields users can insert: { id: "1", label: "Customer Name", - defaultValue: "John Doe", // Optional - metadata: { type: "text" }, // Optional - group: "customer", // Optional category + defaultValue: "John Doe", + metadata: { type: "text" }, }, { id: "2", label: "Signature", - mode: "block", // Force block-level insertion + mode: "block", + fieldType: "signer", }, ], - allowCreate: true, // Let users create new fields on the fly + allowCreate: true, + }} +/> +``` + +### Field types + +Tag fields with a `fieldType` to distinguish roles: + +```tsx + ``` +Import the optional CSS to color-code fields in the editor: + +```jsx +import "@superdoc-dev/template-builder/field-types.css"; +``` + +Customize colors with CSS variables: + +```css +:root { + --superdoc-field-owner-color: #629be7; + --superdoc-field-signer-color: #d97706; +} +``` + +The `fieldType` value flows through all callbacks (`onFieldInsert`, `onFieldsChange`, `onExport`, etc.) and is stored in the SDT tag metadata. + ### Field creation Allow users to create new fields while building templates: @@ -60,7 +93,9 @@ Allow users to create new fields while building templates: allowCreate: true, }} onFieldCreate={async (field) => { - // Validate or save to database + // field.id starts with "custom_" + // field.fieldType is "owner" or "signer" (user-selected) + // field.mode is "inline" or "block" (user-selected) const savedField = await api.createField(field); // Return updated field or void @@ -69,7 +104,13 @@ Allow users to create new fields while building templates: /> ``` -When enabled, the field menu shows a "Create new field" option at the bottom. +When enabled, the field menu shows a "Create New Field" option with inputs for name, mode (inline/block), and field type (owner/signer). + +### 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. + +The menu automatically groups existing fields and shows the count. When the last field in a group is deleted, the remaining field's group tag is removed. ## Menu customization @@ -90,23 +131,42 @@ Change what opens the field insertion menu: Replace the default field menu entirely: ```tsx -import { DefaultFieldMenu } from "@superdoc-dev/template-builder/defaults"; +function CustomMenu({ + isVisible, + position, + filteredFields, + filterQuery, + existingFields, + allowCreate, + onSelect, + onSelectExisting, + onClose, +}) { + if (!isVisible) return null; -function CustomMenu({ fields, onInsert, onClose, position, query }) { return ( -
- { - /* filter logic */ - }} - /> - {fields.map((field) => ( - + ))} +
+ )} + +

Available fields

+ {filteredFields.map((field) => ( + ))} + +
); } @@ -114,7 +174,7 @@ function CustomMenu({ fields, onInsert, onClose, position, query }) { ; ``` -The component handles trigger detection and positioning, you just render the UI. +The component handles trigger detection, filtering, and positioning. You render the UI. ## List sidebar @@ -123,7 +183,7 @@ The component handles trigger detection and positioning, you just render the UI. ```tsx ``` @@ -135,29 +195,25 @@ Omit `list` prop entirely to hide the sidebar. Replace the default sidebar: ```tsx -function CustomFieldList({ - fields, - selectedField, - onFieldSelect, - onFieldDelete, -}) { +function CustomFieldList({ fields, onSelect, onDelete, selectedFieldId }) { return (