Files
edentext/src/lib/editor/extensions/placeholderField.ts
T
Steffen Becker 0753caa5a9 feat: placeholder fields (inline atom + ODF/DOCX round trip)
An inline atom drawn gray as ‹label›: a click selects the whole field,
typing replaces it, Tab/Shift-Tab jump between fields. Exports to ODF
<text:placeholder> (PLH sentinel U+E022) and a DOCX w:sdt content
control tagged edentext-placeholder; both importers read it back, the
export-minted gray stripped. Covered in roundtrip Leg 31 + fuzzDoc.
2026-08-24 16:16:24 +00:00

94 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Node, mergeAttributes, type Editor } from '@tiptap/core';
import { NodeSelection } from '@tiptap/pm/state';
// Inline atom for a template placeholder (LibreOffice's Insert ▸ Field ▸ Placeholder):
// gray ‹label› text; a click selects the whole field and typing replaces it.
// Round-trips to ODF <text:placeholder> and a tagged DOCX <w:sdt> content control.
export interface PlaceholderFieldAttrs {
/** The label shown inside ‹ ›, without the brackets. */
text: string;
}
export function placeholderFieldText(attrs: PlaceholderFieldAttrs): string {
return `‹${attrs.text}›`;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
placeholderField: {
insertPlaceholderField: (text: string) => ReturnType;
};
}
}
// Tab/Shift-Tab from a selected placeholder jump to the next/previous one, wrapping.
function selectAdjacentPlaceholder(editor: Editor, dir: 1 | -1): boolean {
const { state } = editor;
const sel = state.selection;
if (!(sel instanceof NodeSelection) || sel.node.type.name !== 'placeholderField') return false;
const positions: number[] = [];
state.doc.descendants((node, pos) => {
if (node.type.name === 'placeholderField') positions.push(pos);
});
const i = positions.indexOf(sel.from);
if (i < 0 || positions.length < 2) return true; // consume: Tab must not replace the field
const next = positions[(i + dir + positions.length) % positions.length];
editor.view.dispatch(state.tr.setSelection(NodeSelection.create(state.doc, next)).scrollIntoView());
return true;
}
export const PlaceholderField = Node.create({
name: 'placeholderField',
group: 'inline',
inline: true,
atom: true,
// Above Indent (1000) so Tab reaches this keymap first while a field is selected.
priority: 1100,
addAttributes() {
return {
text: {
default: '',
parseHTML: (el) => el.getAttribute('data-placeholder-field') || '',
renderHTML: (attrs) => ({ 'data-placeholder-field': attrs.text }),
},
};
},
parseHTML() {
return [{ tag: 'span[data-placeholder-field]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), placeholderFieldText(node.attrs as PlaceholderFieldAttrs)];
},
renderText({ node }) {
return placeholderFieldText(node.attrs as PlaceholderFieldAttrs);
},
addKeyboardShortcuts() {
return {
Tab: () => selectAdjacentPlaceholder(this.editor, 1),
'Shift-Tab': () => selectAdjacentPlaceholder(this.editor, -1),
};
},
addCommands() {
return {
insertPlaceholderField: (text) => ({ commands, state }) => {
// Adopt the cursor's marks so the field renders like the surrounding text.
const marks = (state.storedMarks ?? state.selection.$to.marks())
.map((m) => ({ type: m.type.name, attrs: m.attrs }));
return commands.insertContent({
type: this.name,
attrs: { text },
...(marks.length ? { marks } : {}),
});
},
};
},
});