@ai-composer/dom 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +5 -2
  3. package/dist/icons.d.ts +11 -0
  4. package/dist/icons.d.ts.map +1 -0
  5. package/dist/icons.js +46 -0
  6. package/dist/icons.js.map +1 -0
  7. package/dist/index.d.ts +20 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +20 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/mount.d.ts +41 -0
  12. package/dist/mount.d.ts.map +1 -0
  13. package/dist/mount.js +168 -0
  14. package/dist/mount.js.map +1 -0
  15. package/dist/package.json +25 -0
  16. package/dist/parse.d.ts +13 -0
  17. package/dist/parse.d.ts.map +1 -0
  18. package/dist/parse.js +133 -0
  19. package/dist/parse.js.map +1 -0
  20. package/dist/popup.d.ts +34 -0
  21. package/dist/popup.d.ts.map +1 -0
  22. package/dist/popup.js +121 -0
  23. package/dist/popup.js.map +1 -0
  24. package/dist/render.d.ts +17 -0
  25. package/dist/render.d.ts.map +1 -0
  26. package/dist/render.js +84 -0
  27. package/dist/render.js.map +1 -0
  28. package/dist/selection.d.ts +13 -0
  29. package/dist/selection.d.ts.map +1 -0
  30. package/dist/selection.js +110 -0
  31. package/dist/selection.js.map +1 -0
  32. package/dist/suggestions.d.ts +23 -0
  33. package/dist/suggestions.d.ts.map +1 -0
  34. package/dist/suggestions.js +113 -0
  35. package/dist/suggestions.js.map +1 -0
  36. package/dist/surface.d.ts +40 -0
  37. package/dist/surface.d.ts.map +1 -0
  38. package/dist/surface.js +372 -0
  39. package/dist/surface.js.map +1 -0
  40. package/dist/template.d.ts +26 -0
  41. package/dist/template.d.ts.map +1 -0
  42. package/dist/template.js +47 -0
  43. package/dist/template.js.map +1 -0
  44. package/package.json +21 -3
  45. package/project.json +56 -0
  46. package/src/icons.ts +51 -0
  47. package/src/index.ts +50 -0
  48. package/src/mount.ts +218 -0
  49. package/src/parse.ts +135 -0
  50. package/src/popup.ts +153 -0
  51. package/src/render.ts +88 -0
  52. package/src/selection.ts +141 -0
  53. package/src/suggestions.ts +146 -0
  54. package/src/surface.test.ts +266 -0
  55. package/src/surface.ts +433 -0
  56. package/src/template.ts +80 -0
  57. package/tsconfig.json +15 -0
  58. package/tsconfig.lib.json +15 -0
  59. package/tsconfig.spec.json +16 -0
  60. package/vitest.config.ts +16 -0
package/src/mount.ts ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Vanilla JS mount — proves the stack works with zero framework. Builds the
3
+ * DOM once (every slot exists), then applies the mode's structural preset and
4
+ * re-applies it live when the editor's mode changes — the draft, focus and
5
+ * undo history survive a compact ↔ chat ↔ expanded switch.
6
+ */
7
+
8
+ import type { AIComposer, SuggestionItem, Unsubscribe } from '@ai-composer/core';
9
+ import { createEditableSurface, type EditableSurface } from './surface';
10
+ import { createSuggestionList } from './suggestions';
11
+ import { createIcon, createSpinner } from './icons';
12
+ import { templateForMode, SLOT_ATTRIBUTE, type AIComposerDomTemplate } from './template';
13
+
14
+ export interface MountOptions {
15
+ /** Initial mode preset; can be changed later via `editor.setMode()`. */
16
+ mode?: string;
17
+ /** Replace the structural preset (static — not mode-switched). */
18
+ template?: AIComposerDomTemplate;
19
+ /**
20
+ * Force slots on regardless of the mode preset (projection-friendly):
21
+ * attachments/header/toolbar/footer stay mountable in compact too.
22
+ */
23
+ showSlots?: Array<'header' | 'attachments' | 'toolbar' | 'footer'>;
24
+ /**
25
+ * Auto-height ceiling — any CSS length ("160px", "40vh"). The box grows
26
+ * with content up to this height, then scrolls inside.
27
+ * Equivalent to the `--aic-input-max-height` token.
28
+ */
29
+ maxHeight?: string;
30
+ /** Custom suggestion item renderer. */
31
+ renderSuggestionItem?: (item: SuggestionItem, active: boolean) => HTMLElement;
32
+ labels?: { removeAttachment?: string };
33
+ }
34
+
35
+ export interface MountedEditor {
36
+ root: HTMLElement;
37
+ input: HTMLElement;
38
+ suggestions: HTMLElement;
39
+ surface: EditableSurface;
40
+ slots: Record<string, HTMLElement>;
41
+ destroy(): void;
42
+ }
43
+
44
+ export function mountAIComposer(
45
+ container: HTMLElement,
46
+ editor: AIComposer,
47
+ options: MountOptions = {},
48
+ ): MountedEditor {
49
+ const labels = {
50
+ removeAttachment: options.labels?.removeAttachment ?? 'Remove',
51
+ };
52
+
53
+ const slots: Record<string, HTMLElement> = {};
54
+ const disposables: Unsubscribe[] = [];
55
+
56
+ const makeSlot = (name: string, className: string): HTMLElement => {
57
+ const element = document.createElement('div');
58
+ element.className = className;
59
+ element.setAttribute(SLOT_ATTRIBUTE, name);
60
+ slots[name] = element;
61
+ return element;
62
+ };
63
+
64
+ const root = container;
65
+ root.classList.add('aic-root');
66
+ if (options.maxHeight) {
67
+ root.style.setProperty('--aic-input-max-height', options.maxHeight);
68
+ }
69
+
70
+ const header = makeSlot('header', 'aic-header');
71
+ const body = makeSlot('body', 'aic-body');
72
+ const toolbar = makeSlot('toolbar', 'aic-toolbar');
73
+ const footer = makeSlot('footer', 'aic-footer');
74
+ const attachments = makeSlot('attachments', 'aic-attachments');
75
+ const input = makeSlot('input', 'aic-input-host');
76
+
77
+ root.append(header, body, toolbar, footer);
78
+
79
+ // The input wrapper is the popup anchor: it occupies exactly the input's
80
+ // space — the suggestion list floats out of it (no DOM-space reservation).
81
+ const inputWrap = document.createElement('div');
82
+ inputWrap.className = 'aic-input-wrap';
83
+ inputWrap.setAttribute(SLOT_ATTRIBUTE, 'suggestions');
84
+ inputWrap.appendChild(input);
85
+ slots.suggestions = inputWrap;
86
+ body.append(attachments, inputWrap);
87
+
88
+ const surface = createEditableSurface(editor, input, { id: input.id || undefined });
89
+
90
+ const suggestionList = createSuggestionList(editor, undefined, {
91
+ renderItem: options.renderSuggestionItem,
92
+ inputHost: input,
93
+ });
94
+ inputWrap.appendChild(suggestionList.element);
95
+
96
+ // -- mode presets ----------------------------------------------------------
97
+
98
+ const applyTemplate = (mode: string): void => {
99
+ const template = options.template ?? templateForMode(mode);
100
+ const present = new Set(template.slots);
101
+ for (const slotName of options.showSlots ?? []) present.add(slotName);
102
+
103
+ root.setAttribute('data-aic-mode', template.name);
104
+ header.hidden = !present.has('header');
105
+ toolbar.hidden = !present.has('toolbar');
106
+ footer.hidden = !present.has('footer');
107
+ surface.setMultiline(template.multiline);
108
+ surface.setSubmitKey(template.submitKey);
109
+
110
+ if (toolbar) {
111
+ toolbar.textContent = '';
112
+ for (const action of template.toolbarButtons ?? []) {
113
+ const button = document.createElement('button');
114
+ button.type = 'button';
115
+ button.className = `aic-toolbar-${action}`;
116
+ button.setAttribute('data-aic-action', action);
117
+ if (action === 'submit') {
118
+ button.appendChild(createIcon('send'));
119
+ button.setAttribute('aria-label', 'Send');
120
+ button.addEventListener('click', () => void editor.executeCommand('submit'));
121
+ } else if (action === 'undo') {
122
+ button.appendChild(createIcon('undo'));
123
+ button.setAttribute('aria-label', 'Undo');
124
+ button.addEventListener('click', () => void editor.executeCommand('undo'));
125
+ } else {
126
+ button.appendChild(createIcon('redo'));
127
+ button.setAttribute('aria-label', 'Redo');
128
+ button.addEventListener('click', () => void editor.executeCommand('redo'));
129
+ }
130
+ toolbar.appendChild(button);
131
+ }
132
+ }
133
+ };
134
+
135
+ // -- state-driven UI -------------------------------------------------------
136
+
137
+ let currentMode = '';
138
+ const syncUi = (state: ReturnType<AIComposer['getState']>): void => {
139
+ if (state.mode !== currentMode) {
140
+ currentMode = state.mode;
141
+ applyTemplate(state.mode);
142
+ }
143
+
144
+ const submit = toolbar.querySelector<HTMLButtonElement>('[data-aic-action="submit"]');
145
+ if (submit) {
146
+ const canSubmit =
147
+ !state.disabled && !state.readonly && !state.submitting &&
148
+ (!state.empty || state.attachments.length > 0);
149
+ submit.disabled = !canSubmit;
150
+ submit.setAttribute('aria-disabled', String(!canSubmit));
151
+ if (state.submitting) {
152
+ if (!submit.querySelector('.aic-spinner')) {
153
+ submit.textContent = '';
154
+ submit.appendChild(createSpinner());
155
+ }
156
+ } else if (!submit.querySelector('svg')) {
157
+ submit.textContent = '';
158
+ submit.appendChild(createIcon('send'));
159
+ }
160
+ }
161
+ const undo = toolbar.querySelector<HTMLButtonElement>('[data-aic-action="undo"]');
162
+ if (undo) undo.disabled = !state.canUndo;
163
+ const redo = toolbar.querySelector<HTMLButtonElement>('[data-aic-action="redo"]');
164
+ if (redo) redo.disabled = !state.canRedo;
165
+
166
+ // Attachments are projection-friendly: visible whenever they exist,
167
+ // regardless of the mode preset (apps add them via editor.addAttachment).
168
+ attachments.hidden = state.attachments.length === 0;
169
+ attachments.textContent = '';
170
+ for (const attachment of state.attachments) {
171
+ const item = document.createElement('span');
172
+ item.className = 'aic-attachment';
173
+ const name = document.createElement('span');
174
+ name.className = 'aic-attachment-name';
175
+ name.textContent = attachment.name;
176
+ const remove = document.createElement('button');
177
+ remove.type = 'button';
178
+ remove.className = 'aic-attachment-remove';
179
+ remove.setAttribute('aria-label', `${labels.removeAttachment} ${attachment.name}`);
180
+ remove.appendChild(createIcon('close', 12));
181
+ remove.addEventListener('click', () => editor.removeNode(attachment.key));
182
+ item.append(name, remove);
183
+ attachments.appendChild(item);
184
+ }
185
+ };
186
+ const unsubscribeState = editor.subscribe(syncUi);
187
+ disposables.push(unsubscribeState);
188
+
189
+ if (options.mode && options.mode !== editor.getState().mode) {
190
+ editor.setMode(options.mode);
191
+ }
192
+ // setMode emits to subscribers; ensure the initial template is applied even
193
+ // when the editor was already in the target mode.
194
+ currentMode = editor.getState().mode;
195
+ applyTemplate(currentMode);
196
+ syncUi(editor.getState());
197
+
198
+ const offDestroy = editor.on('destroy', () => mounted.destroy());
199
+ disposables.push(offDestroy);
200
+
201
+ const mounted: MountedEditor = {
202
+ root,
203
+ input,
204
+ suggestions: suggestionList.element,
205
+ surface,
206
+ slots,
207
+ destroy() {
208
+ for (const dispose of disposables.splice(0)) dispose();
209
+ suggestionList.destroy();
210
+ surface.destroy();
211
+ root.textContent = '';
212
+ root.classList.remove('aic-root');
213
+ root.removeAttribute('data-aic-mode');
214
+ },
215
+ };
216
+
217
+ return mounted;
218
+ }
package/src/parse.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * DOM → model parsing with strict sanitization: only elements we generate
3
+ * (data-aic-node chips) survive as structured nodes — everything else is
4
+ * flattened to plain text (see the serialization ADR).
5
+ */
6
+
7
+ import {
8
+ createDocument,
9
+ createNodeKey,
10
+ type NodeRegistry,
11
+ type AIComposerNode,
12
+ type TextNode,
13
+ } from '@ai-composer/core';
14
+
15
+ const INTERNAL_CLIP_MIME = 'application/x-ai-composer';
16
+
17
+ function textNode(text: string, previous?: TextNode): TextNode {
18
+ if (previous) return { ...previous, text: previous.text + text };
19
+ return { type: 'text', key: createNodeKey('text'), text };
20
+ }
21
+
22
+ /** Parse the editable host back into a document. Never trusts markup: unknown nodes collapse to text. */
23
+ export function parseEditableHost(host: HTMLElement, registry: NodeRegistry): ReturnType<typeof createDocument> {
24
+ const nodes: AIComposerNode[] = [];
25
+
26
+ const appendText = (text: string): void => {
27
+ if (!text) return;
28
+ const last = nodes[nodes.length - 1];
29
+ if (last && last.type === 'text') nodes[nodes.length - 1] = textNode(text, last);
30
+ else nodes.push(textNode(text));
31
+ };
32
+
33
+ const walk = (parent: Node): void => {
34
+ for (const child of Array.from(parent.childNodes)) {
35
+ if (child.nodeType === Node.TEXT_NODE) {
36
+ appendText(child.textContent ?? '');
37
+ continue;
38
+ }
39
+ if (child.nodeName === 'BR') {
40
+ appendText('\n');
41
+ continue;
42
+ }
43
+ if (child instanceof HTMLElement) {
44
+ // Never surface executable/style content as text.
45
+ if (child.tagName === 'SCRIPT' || child.tagName === 'STYLE') continue;
46
+ const type = child.getAttribute('data-aic-node');
47
+ const parsed = type ? chipFromElement(child, type, registry) : null;
48
+ if (parsed) {
49
+ nodes.push(parsed);
50
+ continue;
51
+ }
52
+ // Unknown wrapper (browser artifact, pasted markup) — flatten to text only.
53
+ walk(child);
54
+ }
55
+ // Comments and other node types are dropped.
56
+ }
57
+ };
58
+
59
+ walk(host);
60
+ return createDocument(nodes);
61
+ }
62
+
63
+ function chipFromElement(element: HTMLElement, type: string, registry: NodeRegistry): AIComposerNode | null {
64
+ if (!registry.has(type)) return null;
65
+ const key = element.getAttribute('data-aic-key') ?? createNodeKey(type);
66
+ const attr = (name: string): string | null => element.getAttribute(`data-aic-${name}`);
67
+
68
+ switch (type) {
69
+ case 'mention':
70
+ case 'command': {
71
+ const id = attr('id');
72
+ const label = attr('label');
73
+ if (!id || !label) return null;
74
+ return type === 'mention'
75
+ ? { type: 'mention', key, id, label }
76
+ : { type: 'command', key, id, label };
77
+ }
78
+ case 'variable': {
79
+ const name = attr('name');
80
+ if (!name) return null;
81
+ return { type: 'variable', key, name };
82
+ }
83
+ case 'attachment': {
84
+ const id = attr('id');
85
+ const name = attr('name');
86
+ const mimeType = attr('mime') ?? 'application/octet-stream';
87
+ if (!id || !name) return null;
88
+ const sizeAttr = attr('size');
89
+ const size = sizeAttr ? Number(sizeAttr) : undefined;
90
+ return { type: 'attachment', key, id, name, mimeType, ...(Number.isFinite(size) ? { size } : {}) };
91
+ }
92
+ case 'custom': {
93
+ const plugin = attr('plugin');
94
+ const nodeType = attr('type');
95
+ if (!plugin || !nodeType) return null;
96
+ let data: unknown = null;
97
+ try {
98
+ data = JSON.parse(attr('data') ?? 'null');
99
+ } catch {
100
+ data = null;
101
+ }
102
+ return { type: 'custom', key, plugin, nodeType, data };
103
+ }
104
+ default:
105
+ // Registered custom node types without a known chip shape degrade to text.
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Extract safe plain text from arbitrary HTML (paste path). Tags are dropped, <br> becomes \n. */
111
+ export function htmlToPlainText(html: string): string {
112
+ const container = document.createElement('div');
113
+ container.innerHTML = html; // parsed, never inserted into the page
114
+ let text = '';
115
+ const walk = (parent: Node): void => {
116
+ for (const child of Array.from(parent.childNodes)) {
117
+ if (child.nodeType === Node.TEXT_NODE) text += child.textContent ?? '';
118
+ else if (child.nodeName === 'BR') text += '\n';
119
+ else if (child.nodeType === Node.ELEMENT_NODE) {
120
+ const element = child as HTMLElement;
121
+ if (element.tagName === 'DIV' || element.tagName === 'P') {
122
+ walk(element);
123
+ text += '\n';
124
+ } else {
125
+ walk(element);
126
+ }
127
+ }
128
+ }
129
+ };
130
+ walk(container);
131
+ return text;
132
+ }
133
+
134
+ /** Clipboard mime used for lossless internal copy/paste round-trips. */
135
+ export const CLIPBOARD_MIME = INTERNAL_CLIP_MIME;
package/src/popup.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Caret-anchored popup positioning. The suggestion menu opens where the
3
+ * trigger key (@, /, …) was hit — not just above the box — and reflows:
4
+ *
5
+ * - horizontal: popup's left edge follows the caret, clamped into the
6
+ * viewport so it never opens out of the window
7
+ * - vertical: prefers `placement` (Copilot-style: above), flips to the
8
+ * other side when the viewport lacks room, caps its max-height to the
9
+ * available space and scrolls inside
10
+ * - repositions on window resize and on any scroll (capture) while open
11
+ *
12
+ * The popup must live inside a positioned ancestor (`.aic-input-wrap`) —
13
+ * coordinates are computed relative to that offset parent each update.
14
+ */
15
+
16
+ export interface CaretPoint {
17
+ /** Viewport x of the caret. */
18
+ x: number;
19
+ /** Viewport y of the caret line's top / bottom. */
20
+ top: number;
21
+ bottom: number;
22
+ }
23
+
24
+ export interface CaretAnchor {
25
+ /** Recompute position (call after content/visibility changes). */
26
+ update(): void;
27
+ destroy(): void;
28
+ }
29
+
30
+ export interface CaretAnchorOptions {
31
+ /** Preferred side; flips automatically when the viewport lacks room. */
32
+ placement?: 'above' | 'below';
33
+ }
34
+
35
+ const GAP = 6;
36
+ const EDGE = 8;
37
+
38
+ /** Measure the caret inside a contenteditable host (viewport coordinates). */
39
+ export function measureCaret(host: HTMLElement): CaretPoint | null {
40
+ const doc = host.ownerDocument;
41
+ const win = doc.defaultView;
42
+ if (!win) return null;
43
+ const selection = win.getSelection();
44
+ if (selection && selection.rangeCount > 0 && host.contains(selection.focusNode)) {
45
+ const range = selection.getRangeAt(0).cloneRange();
46
+ const rects = range.getClientRects();
47
+ if (rects.length > 0) {
48
+ const rect = rects[0];
49
+ return { x: rect.left, top: rect.top, bottom: rect.bottom };
50
+ }
51
+ // Collapsed caret at an element boundary reports no rects — probe with a
52
+ // zero-width marker and remove it synchronously (no input event fires,
53
+ // so the editor's view pipeline is never involved).
54
+ const marker = doc.createElement('span');
55
+ marker.textContent = '​';
56
+ try {
57
+ range.insertNode(marker);
58
+ const rect = marker.getBoundingClientRect();
59
+ if (rect.width === 0 && rect.height === 0) return null;
60
+ return { x: rect.left, top: rect.top, bottom: rect.bottom };
61
+ } catch {
62
+ return null;
63
+ } finally {
64
+ marker.remove();
65
+ }
66
+ }
67
+ return null;
68
+ }
69
+
70
+ /** Fallback: the first text line's start (empty host, no selection). */
71
+ function firstLine(host: HTMLElement): CaretPoint {
72
+ const rect = host.getBoundingClientRect();
73
+ const style = host.ownerDocument.defaultView?.getComputedStyle(host);
74
+ const padLeft = Number.parseFloat(style?.paddingLeft ?? '0') || 0;
75
+ const padTop = Number.parseFloat(style?.paddingTop ?? '0') || 0;
76
+ return { x: rect.left + padLeft, top: rect.top + padTop, bottom: rect.top + padTop + 22 };
77
+ }
78
+
79
+ export function attachCaretAnchoredPopup(
80
+ popup: HTMLElement,
81
+ host: HTMLElement,
82
+ options: CaretAnchorOptions = {},
83
+ ): CaretAnchor {
84
+ const win = host.ownerDocument.defaultView ?? window;
85
+ const prefer = options.placement ?? 'above';
86
+ const scrollOptions: AddEventListenerOptions & EventListenerOptions = {
87
+ capture: true,
88
+ passive: true,
89
+ };
90
+
91
+ const update = (): void => {
92
+ if (popup.hidden) return;
93
+ const viewportWidth = win.innerWidth;
94
+ const viewportHeight = win.innerHeight;
95
+
96
+ const width = Math.max(180, Math.min(360, viewportWidth - EDGE * 2));
97
+ popup.style.position = 'absolute';
98
+ popup.style.width = `${Math.round(width)}px`;
99
+ popup.style.margin = '0';
100
+ popup.style.zIndex = 'var(--aic-z-suggestions)';
101
+
102
+ // Measure the anchor AFTER taking the popup out of flow — otherwise the
103
+ // wrap's rect still includes the freshly-opened list and the popup
104
+ // detaches by exactly its own height.
105
+ const wrap = popup.parentElement ?? host;
106
+ const wrapRect = wrap.getBoundingClientRect();
107
+ const point = measureCaret(host) ?? firstLine(host);
108
+
109
+ // Horizontal — under the caret, clamped so the popup stays on screen.
110
+ const desiredLeft = point.x - wrapRect.left - 4;
111
+ const absoluteLeft = Math.min(
112
+ Math.max(wrapRect.left + desiredLeft, EDGE),
113
+ viewportWidth - width - EDGE,
114
+ );
115
+ popup.style.left = `${Math.round(absoluteLeft - wrapRect.left)}px`;
116
+ popup.style.right = 'auto';
117
+
118
+ // Vertical — preferred side first, flip when the viewport lacks room.
119
+ popup.style.maxHeight = '';
120
+ const needed = Math.min(popup.offsetHeight || 200, 260);
121
+ const spaceAbove = point.top - EDGE;
122
+ const spaceBelow = viewportHeight - point.bottom - EDGE;
123
+ let side = prefer;
124
+ const wanted = Math.min(needed, 140);
125
+ if (side === 'above' && spaceAbove < wanted && spaceBelow > spaceAbove) side = 'below';
126
+ else if (side === 'below' && spaceBelow < wanted && spaceAbove > spaceBelow) side = 'above';
127
+
128
+ const available = Math.max(
129
+ 96,
130
+ Math.min(260, (side === 'above' ? spaceAbove : spaceBelow) - GAP),
131
+ );
132
+ popup.style.maxHeight = `${Math.round(available)}px`;
133
+ if (side === 'above') {
134
+ popup.style.bottom = `${Math.round(wrapRect.bottom - point.top + GAP)}px`;
135
+ popup.style.top = 'auto';
136
+ } else {
137
+ popup.style.top = `${Math.round(point.bottom - wrapRect.top + GAP)}px`;
138
+ popup.style.bottom = 'auto';
139
+ }
140
+ };
141
+
142
+ const onReflow = (): void => update();
143
+ win.addEventListener('resize', onReflow);
144
+ win.addEventListener('scroll', onReflow, scrollOptions);
145
+
146
+ return {
147
+ update,
148
+ destroy() {
149
+ win.removeEventListener('resize', onReflow);
150
+ win.removeEventListener('scroll', onReflow, scrollOptions);
151
+ },
152
+ };
153
+ }
package/src/render.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Model → DOM rendering. The DOM is a projection of the authoritative
3
+ * document (ADR-0001). Atomic nodes render as non-editable chips carrying
4
+ * data-aic-* attributes so parse.ts can round-trip them safely.
5
+ */
6
+
7
+ import type { AIComposerDocument, AIComposerNode } from '@ai-composer/core';
8
+ import type { NodeRegistry } from '@ai-composer/core';
9
+
10
+ function displayOf(node: AIComposerNode, registry: NodeRegistry): string {
11
+ const definition = registry.get(node.type);
12
+ return definition?.toDisplay?.(node) ?? '';
13
+ }
14
+
15
+ /** Render one atomic node as a non-editable chip element. */
16
+ export function renderChip(node: AIComposerNode, registry: NodeRegistry): HTMLElement {
17
+ const chip = document.createElement('span');
18
+ chip.setAttribute('contenteditable', 'false');
19
+ chip.className = 'aic-chip';
20
+ chip.setAttribute('data-aic-key', node.key);
21
+ chip.setAttribute('data-aic-node', node.type);
22
+ chip.hidden = node.type === 'attachment';
23
+ chip.setAttribute('aria-label', displayOf(node, registry));
24
+
25
+ switch (node.type) {
26
+ case 'mention':
27
+ chip.setAttribute('data-aic-id', node.id);
28
+ chip.setAttribute('data-aic-label', node.label);
29
+ break;
30
+ case 'command':
31
+ chip.setAttribute('data-aic-id', node.id);
32
+ chip.setAttribute('data-aic-label', node.label);
33
+ break;
34
+ case 'variable':
35
+ chip.setAttribute('data-aic-name', node.name);
36
+ break;
37
+ case 'attachment':
38
+ chip.setAttribute('data-aic-id', node.id);
39
+ chip.setAttribute('data-aic-name', node.name);
40
+ chip.setAttribute('data-aic-mime', node.mimeType);
41
+ if (node.size !== undefined) chip.setAttribute('data-aic-size', String(node.size));
42
+ break;
43
+ case 'custom':
44
+ chip.setAttribute('data-aic-plugin', node.plugin);
45
+ chip.setAttribute('data-aic-type', node.nodeType);
46
+ chip.setAttribute('data-aic-data', safeJson(node.data));
47
+ break;
48
+ default:
49
+ break;
50
+ }
51
+
52
+ chip.textContent = displayOf(node, registry);
53
+ return chip;
54
+ }
55
+
56
+ function safeJson(value: unknown): string {
57
+ try {
58
+ return JSON.stringify(value ?? null);
59
+ } catch {
60
+ return 'null';
61
+ }
62
+ }
63
+
64
+ /** Render a text run; newlines become <br> so the editable area shows line breaks. */
65
+ export function renderTextRun(text: string): Node {
66
+ if (!text.includes('\n')) return document.createTextNode(text);
67
+ const fragment = document.createDocumentFragment();
68
+ const parts = text.split('\n');
69
+ parts.forEach((part, index) => {
70
+ if (part) fragment.appendChild(document.createTextNode(part));
71
+ if (index < parts.length - 1) fragment.appendChild(document.createElement('br'));
72
+ });
73
+ return fragment;
74
+ }
75
+
76
+ /**
77
+ * Render the document into `host`, replacing previous content.
78
+ * The placeholder <br> keeps the host clickable when empty.
79
+ */
80
+ export function renderDocument(host: HTMLElement, document_: AIComposerDocument, registry: NodeRegistry): void {
81
+ host.textContent = '';
82
+ for (const node of document_.nodes) {
83
+ host.appendChild(node.type === 'text' ? renderTextRun(node.text) : renderChip(node, registry));
84
+ }
85
+ if (document_.nodes.length === 0) {
86
+ host.appendChild(document.createElement('br'));
87
+ }
88
+ }