@1agh/maude 1.4.6 → 1.5.1

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 (100) hide show
  1. package/apps/studio/annotations/ai-read.ts +288 -0
  2. package/apps/studio/annotations/ai-write.ts +534 -0
  3. package/apps/studio/annotations/board-io.ts +94 -0
  4. package/apps/studio/annotations/board-text.ts +45 -0
  5. package/apps/studio/annotations/constants.ts +58 -0
  6. package/apps/studio/annotations/elements/_shared.ts +125 -0
  7. package/apps/studio/annotations/elements/arrow.model.ts +196 -0
  8. package/apps/studio/annotations/elements/media.model.ts +79 -0
  9. package/apps/studio/annotations/elements/pen.model.ts +82 -0
  10. package/apps/studio/annotations/elements/section.model.ts +70 -0
  11. package/apps/studio/annotations/elements/shape.model.ts +141 -0
  12. package/apps/studio/annotations/elements/sticky.model.ts +48 -0
  13. package/apps/studio/annotations/elements/text.model.ts +52 -0
  14. package/apps/studio/annotations/fields.ts +347 -0
  15. package/apps/studio/annotations/fractional-index.ts +234 -0
  16. package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
  17. package/apps/studio/annotations/migrate-boot.ts +172 -0
  18. package/apps/studio/annotations/migrate-cli.ts +37 -0
  19. package/apps/studio/annotations/migrate-v1.ts +383 -0
  20. package/apps/studio/annotations/ops-merge.ts +15 -0
  21. package/apps/studio/annotations/ops.ts +499 -0
  22. package/apps/studio/annotations/registry.ts +159 -0
  23. package/apps/studio/annotations/replica.ts +264 -0
  24. package/apps/studio/annotations/scene.ts +235 -0
  25. package/apps/studio/annotations/schema.ts +188 -0
  26. package/apps/studio/annotations/types.ts +77 -0
  27. package/apps/studio/annotations/ui/board.ts +126 -0
  28. package/apps/studio/annotations/ui/containment.ts +118 -0
  29. package/apps/studio/annotations/ui/edit-actions.ts +551 -0
  30. package/apps/studio/annotations/ui/editor-channel.ts +62 -0
  31. package/apps/studio/annotations/ui/element-node.tsx +993 -0
  32. package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
  33. package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
  34. package/apps/studio/annotations/ui/render-model.ts +264 -0
  35. package/apps/studio/annotations/ui/scene.tsx +63 -0
  36. package/apps/studio/annotations/ui/text-editor.tsx +420 -0
  37. package/apps/studio/annotations/ui/text-session.ts +140 -0
  38. package/apps/studio/annotations/ui/text-style.ts +111 -0
  39. package/apps/studio/annotations/ui/world.ts +47 -0
  40. package/apps/studio/annotations/v1-adapter.ts +428 -0
  41. package/apps/studio/annotations-align.ts +21 -6
  42. package/apps/studio/annotations-bindings.ts +2 -0
  43. package/apps/studio/annotations-context-toolbar.tsx +9 -13
  44. package/apps/studio/annotations-groups.ts +3 -0
  45. package/apps/studio/annotations-layer.tsx +923 -1925
  46. package/apps/studio/annotations-model.ts +65 -4
  47. package/apps/studio/annotations-sync.ts +4 -47
  48. package/apps/studio/api.ts +249 -69
  49. package/apps/studio/bin/_import-figma.mjs +22 -12
  50. package/apps/studio/bin/annotate.mjs +331 -838
  51. package/apps/studio/bin/annotate.sh +4 -4
  52. package/apps/studio/bin/perf.sh +21 -7
  53. package/apps/studio/bin/read-annotations.mjs +184 -666
  54. package/apps/studio/bin/read-annotations.sh +9 -5
  55. package/apps/studio/canvas-artifacts.ts +9 -0
  56. package/apps/studio/canvas-lib.tsx +13 -1
  57. package/apps/studio/canvas-shell.tsx +6 -0
  58. package/apps/studio/client/app.jsx +98 -35
  59. package/apps/studio/client/hmr.mjs +1 -1
  60. package/apps/studio/client/panels/git-grouping.js +2 -2
  61. package/apps/studio/client/tree-expansion.js +217 -0
  62. package/apps/studio/collab/index.ts +49 -5
  63. package/apps/studio/collab/persistence.ts +31 -10
  64. package/apps/studio/collab/registry.ts +64 -26
  65. package/apps/studio/commands/annotation-ops-command.ts +72 -0
  66. package/apps/studio/cursors-overlay.tsx +158 -4
  67. package/apps/studio/dist/client.bundle.js +850 -850
  68. package/apps/studio/dist/comment-mount.js +2 -2
  69. package/apps/studio/figma/to-strokes.ts +31 -10
  70. package/apps/studio/git/endpoints.ts +1 -1
  71. package/apps/studio/git/service.ts +1 -1
  72. package/apps/studio/git/watch.ts +1 -1
  73. package/apps/studio/http.ts +90 -15
  74. package/apps/studio/server.ts +16 -0
  75. package/apps/studio/sync/accepted-cold-start.ts +12 -4
  76. package/apps/studio/sync/agent.ts +8 -4
  77. package/apps/studio/sync/codec.ts +96 -40
  78. package/apps/studio/sync/file-membership.ts +12 -2
  79. package/apps/studio/sync/file-plane.ts +1 -1
  80. package/apps/studio/sync/index.ts +38 -16
  81. package/apps/studio/sync/journal-client.ts +5 -0
  82. package/apps/studio/sync/limits.ts +7 -2
  83. package/apps/studio/sync/migrate-seed.ts +8 -5
  84. package/apps/studio/sync/projection.ts +7 -2
  85. package/apps/studio/sync/remote-docs.ts +34 -0
  86. package/apps/studio/sync/writer-registry.ts +10 -0
  87. package/apps/studio/text-caret.ts +11 -2
  88. package/apps/studio/tree-state.ts +45 -0
  89. package/apps/studio/undo-stack.ts +2 -2
  90. package/apps/studio/use-annotation-resize.tsx +48 -23
  91. package/apps/studio/use-annotation-selection.tsx +9 -2
  92. package/apps/studio/use-collab.tsx +76 -0
  93. package/apps/studio/whats-new.json +27 -0
  94. package/cli/lib/design-link.mjs +5 -1
  95. package/cli/lib/gitignore-block.mjs +1 -1
  96. package/cli/lib/gitignore-drift.mjs +2 -1
  97. package/package.json +9 -8
  98. package/plugins/design/templates/brief-board.tsx.template +1 -1
  99. package/apps/studio/annotation-edit-base.ts +0 -36
  100. package/apps/studio/commands/annotation-strokes-command.ts +0 -137
@@ -0,0 +1,420 @@
1
+ /**
2
+ * @file annotations/ui/text-editor.tsx — the one annotation text editor
3
+ * @scope apps/studio/annotations/ui/text-editor.tsx
4
+ * @purpose DDR-242 AD7: every text slot (sticky body, standalone text, shape
5
+ * label, section title, a not-yet-born text) is edited in a plain
6
+ * `<textarea>` that REPLACES the display block in place, with the
7
+ * same class and style (text-style.ts). A textarea gives native
8
+ * IME, caret, selection, word select on double-click, spellcheck
9
+ * and undo in WKWebView — the contentEditable editors and the
10
+ * custom caret they needed are gone.
11
+ *
12
+ * One commit policy for every slot:
13
+ * Enter commits · Shift+Enter inserts a newline (a single-line
14
+ * title commits) · ⌘/Ctrl+Enter commits and chains a sibling ·
15
+ * Esc cancels · blur or a click outside commits · one guard so a
16
+ * session commits at most once.
17
+ * While an IME composition is open nothing commits (WebKit sends
18
+ * `compositionend` BEFORE the Enter keydown that confirms it, with
19
+ * keyCode 229 — the guard covers both orders).
20
+ */
21
+
22
+ import {
23
+ type CSSProperties,
24
+ type KeyboardEvent as ReactKeyboardEvent,
25
+ useCallback,
26
+ useEffect,
27
+ useLayoutEffect,
28
+ useRef,
29
+ useState,
30
+ } from 'react';
31
+ import { formatCommands, formatState } from './editor-channel.ts';
32
+ import {
33
+ type ListStyle,
34
+ stripListMarkers,
35
+ type TextAlign,
36
+ textDecoration,
37
+ withListMarkers,
38
+ } from './text-style.ts';
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // Pure policy (unit-tested in test/annotations-v2-text.test.ts)
42
+
43
+ export type KeyAction = 'commit' | 'commit-chain' | 'cancel' | 'none';
44
+
45
+ export interface KeyInput {
46
+ key: string;
47
+ shiftKey: boolean;
48
+ metaKey: boolean;
49
+ ctrlKey: boolean;
50
+ /** `KeyboardEvent.isComposing` */
51
+ isComposing: boolean;
52
+ keyCode: number;
53
+ }
54
+
55
+ /**
56
+ * Tracks an IME composition. `compositionend` does not end the guard at once:
57
+ * WebKit delivers the Enter that CONFIRMS a candidate after `compositionend`,
58
+ * so the flag clears one macrotask later (`release`, scheduled by the caller).
59
+ */
60
+ export class ImeGuard {
61
+ private open = false;
62
+ private tail = false;
63
+ start(): void {
64
+ this.open = true;
65
+ this.tail = false;
66
+ }
67
+ end(): void {
68
+ this.open = false;
69
+ this.tail = true;
70
+ }
71
+ release(): void {
72
+ this.tail = false;
73
+ }
74
+ composing(e: Pick<KeyInput, 'isComposing' | 'keyCode'>): boolean {
75
+ return this.open || this.tail || e.isComposing || e.keyCode === 229;
76
+ }
77
+ }
78
+
79
+ /** What a keydown means to an editor session. Newlines are the textarea's own default. */
80
+ export function keyAction(e: KeyInput, composing: boolean, singleLine: boolean): KeyAction {
81
+ if (composing) return 'none';
82
+ if (e.key === 'Escape') return 'cancel';
83
+ if (e.key !== 'Enter') return 'none';
84
+ if (e.shiftKey && !singleLine) return 'none';
85
+ return e.metaKey || e.ctrlKey ? 'commit-chain' : 'commit';
86
+ }
87
+
88
+ /** A session commits at most once, whatever fires (Enter, blur, outside click, unmount). */
89
+ export function createCommitGuard<A extends unknown[]>(fn: (...args: A) => void) {
90
+ let done = false;
91
+ return {
92
+ fire(...args: A): boolean {
93
+ if (done) return false;
94
+ done = true;
95
+ fn(...args);
96
+ return true;
97
+ },
98
+ /** Esc — nothing may commit after a cancel. */
99
+ close(): void {
100
+ done = true;
101
+ },
102
+ get done(): boolean {
103
+ return done;
104
+ },
105
+ };
106
+ }
107
+
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+ // Formatting while editing (⌘B/I/U + the context toolbar)
110
+
111
+ export interface EditorFmt {
112
+ bold?: boolean;
113
+ italic?: boolean;
114
+ underline?: boolean;
115
+ strike?: boolean;
116
+ fontSize?: number;
117
+ align?: TextAlign;
118
+ }
119
+
120
+ /**
121
+ * Shared inline formatting for the editor. ⌘/Ctrl + B / I / U toggle while
122
+ * editing and preview live via `style`; the edit-mode context toolbar drives
123
+ * the same state and reads it back through editor-channel.ts. The stroke is not touched until commit — a
124
+ * mid-edit store write would re-render the editor under the user's caret.
125
+ */
126
+ export function useEditorFormat(initial: EditorFmt): {
127
+ fmtRef: { current: EditorFmt };
128
+ style: CSSProperties;
129
+ onFormatKey: (e: ReactKeyboardEvent) => boolean;
130
+ } {
131
+ const [bold, setBold] = useState(!!initial.bold);
132
+ const [italic, setItalic] = useState(!!initial.italic);
133
+ const [underline, setUnderline] = useState(!!initial.underline);
134
+ const [strike, setStrike] = useState(!!initial.strike);
135
+ const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
136
+ const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
137
+ const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
138
+ fmtRef.current = { bold, italic, underline, strike, fontSize, align };
139
+ const style: CSSProperties = {
140
+ fontWeight: bold ? 700 : undefined,
141
+ fontStyle: italic ? 'italic' : undefined,
142
+ textDecoration: textDecoration(strike, underline),
143
+ ...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
144
+ ...(align && align !== initial.align ? { textAlign: align } : {}),
145
+ };
146
+ useEffect(
147
+ () =>
148
+ formatCommands.listen((d) => {
149
+ if (d.key === 'bold') setBold((v) => !v);
150
+ else if (d.key === 'italic') setItalic((v) => !v);
151
+ else if (d.key === 'underline') setUnderline((v) => !v);
152
+ else if (d.key === 'strike') setStrike((v) => !v);
153
+ else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
154
+ else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
155
+ }),
156
+ []
157
+ );
158
+ useEffect(() => {
159
+ formatState.publish({ bold, italic, underline, strike, fontSize, align });
160
+ }, [bold, italic, underline, strike, fontSize, align]);
161
+ // The session is over: the toolbar stops mirroring it.
162
+ useEffect(() => () => formatState.publish(null), []);
163
+ const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
164
+ if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
165
+ const k = e.key.toLowerCase();
166
+ if (k === 'b') setBold((v) => !v);
167
+ else if (k === 'i') setItalic((v) => !v);
168
+ else if (k === 'u') setUnderline((v) => !v);
169
+ else return false;
170
+ e.preventDefault();
171
+ return true;
172
+ }, []);
173
+ return { fmtRef, style, onFormatKey };
174
+ }
175
+
176
+ // ─────────────────────────────────────────────────────────────────────────────
177
+ // Caret at the entry click
178
+
179
+ /**
180
+ * Character offset under a client point, measured on a throwaway mirror of the
181
+ * textarea (same class, same inline style, same box). A textarea's content is
182
+ * not reachable by `caretRangeFromPoint`, a div with identical metrics is.
183
+ */
184
+ export function offsetFromPoint(
185
+ ta: HTMLTextAreaElement,
186
+ clientX: number,
187
+ clientY: number
188
+ ): number | null {
189
+ const doc = ta.ownerDocument;
190
+ const parent = ta.parentElement;
191
+ if (!doc || !parent) return null;
192
+ const mirror = doc.createElement('div');
193
+ mirror.className = ta.className;
194
+ mirror.style.cssText = ta.style.cssText;
195
+ mirror.style.position = 'absolute';
196
+ mirror.style.left = `${ta.offsetLeft}px`;
197
+ mirror.style.top = `${ta.offsetTop}px`;
198
+ mirror.style.width = `${ta.offsetWidth}px`;
199
+ mirror.style.height = `${ta.offsetHeight}px`;
200
+ mirror.style.zIndex = '2147483647';
201
+ mirror.style.pointerEvents = 'auto';
202
+ mirror.style.visibility = 'visible';
203
+ mirror.style.color = 'transparent';
204
+ // A trailing newline needs a line box to be hit.
205
+ mirror.textContent = ta.value.endsWith('\n') ? `${ta.value} ` : ta.value;
206
+ parent.insertBefore(mirror, ta);
207
+ try {
208
+ type CaretDoc = Document & {
209
+ caretPositionFromPoint?: (
210
+ x: number,
211
+ y: number
212
+ ) => { offsetNode: Node; offset: number } | null;
213
+ };
214
+ const d = doc as CaretDoc;
215
+ let node: Node | null = null;
216
+ let offset = 0;
217
+ if (typeof d.caretPositionFromPoint === 'function') {
218
+ const p = d.caretPositionFromPoint(clientX, clientY);
219
+ if (p) {
220
+ node = p.offsetNode;
221
+ offset = p.offset;
222
+ }
223
+ } else if (typeof doc.caretRangeFromPoint === 'function') {
224
+ const r = doc.caretRangeFromPoint(clientX, clientY);
225
+ if (r) {
226
+ node = r.startContainer;
227
+ offset = r.startOffset;
228
+ }
229
+ }
230
+ if (!node || !mirror.contains(node)) return null;
231
+ return Math.min(offset, ta.value.length);
232
+ } finally {
233
+ mirror.remove();
234
+ }
235
+ }
236
+
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ // The editor
239
+
240
+ export type SizeMode =
241
+ /** Grows downward only (sticky body): at least the card height. */
242
+ | { kind: 'grow-height'; minHeight: number }
243
+ /** Fills its box width; height follows content (shape label). */
244
+ | { kind: 'fit-height' }
245
+ /** No wrapping; width and height follow content (standalone text, section title). */
246
+ | { kind: 'fit-both'; minWidth?: number };
247
+
248
+ export interface CommitInfo {
249
+ text: string;
250
+ fmt: EditorFmt;
251
+ /** The textarea's laid-out size in world units (the pan/zoom transform sits above it). */
252
+ measured: { w: number; h: number };
253
+ chain: boolean;
254
+ }
255
+
256
+ export interface TextEditorProps {
257
+ /** The slot's text when the session opened — the session base (issue #106 C3). */
258
+ initialText: string;
259
+ list?: ListStyle;
260
+ singleLine?: boolean;
261
+ className: string;
262
+ style: CSSProperties;
263
+ size: SizeMode;
264
+ fmt: EditorFmt;
265
+ /** Client point of the click that opened the editor; null = keyboard entry (select all). */
266
+ caretPoint: { x: number; y: number } | null;
267
+ ariaLabel: string;
268
+ onCommit: (info: CommitInfo) => void;
269
+ onCancel: () => void;
270
+ /** Idle-typing draft hook (Task 19): the current text, list markers stripped. */
271
+ onDraft?: (text: string) => void;
272
+ }
273
+
274
+ const DRAFT_IDLE_MS = 600;
275
+
276
+ export function TextEditor(props: TextEditorProps) {
277
+ const {
278
+ initialText,
279
+ list,
280
+ singleLine = false,
281
+ className,
282
+ style,
283
+ size,
284
+ fmt: initialFmt,
285
+ caretPoint,
286
+ ariaLabel,
287
+ } = props;
288
+ const ref = useRef<HTMLTextAreaElement | null>(null);
289
+ const [value, setValue] = useState(() => withListMarkers(initialText, list));
290
+ const valueRef = useRef(value);
291
+ valueRef.current = value;
292
+ const { fmtRef, style: fmtStyle, onFormatKey } = useEditorFormat(initialFmt);
293
+ const ime = useRef(new ImeGuard()).current;
294
+ const propsRef = useRef(props);
295
+ propsRef.current = props;
296
+
297
+ const guard = useRef(
298
+ createCommitGuard((chain: boolean) => {
299
+ const ta = ref.current;
300
+ propsRef.current.onCommit({
301
+ text: stripListMarkers(valueRef.current, propsRef.current.list),
302
+ fmt: fmtRef.current,
303
+ measured: { w: ta?.offsetWidth ?? 0, h: ta?.scrollHeight ?? 0 },
304
+ chain,
305
+ });
306
+ })
307
+ ).current;
308
+
309
+ const commit = useCallback((chain = false) => guard.fire(chain), [guard]);
310
+ const cancel = useCallback(() => {
311
+ if (guard.done) return;
312
+ guard.close();
313
+ propsRef.current.onCancel();
314
+ }, [guard]);
315
+
316
+ // Size follows content. Layout effect: no frame paints at the stale size.
317
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `value` + format changes resize the box
318
+ useLayoutEffect(() => {
319
+ const ta = ref.current;
320
+ if (!ta) return;
321
+ if (size.kind === 'fit-both') {
322
+ ta.style.width = '0px';
323
+ ta.style.width = `${Math.max(size.minWidth ?? 8, ta.scrollWidth + 2)}px`;
324
+ }
325
+ ta.style.height = '0px';
326
+ const h = ta.scrollHeight;
327
+ ta.style.height = `${size.kind === 'grow-height' ? Math.max(size.minHeight, h) : h}px`;
328
+ }, [value, fmtStyle.fontSize, fmtStyle.fontWeight, size]);
329
+
330
+ // Focus + caret on entry: at the click, else select all (the rename convention).
331
+ useEffect(() => {
332
+ const ta = ref.current;
333
+ if (!ta) return;
334
+ ta.focus({ preventScroll: true });
335
+ const at = caretPoint ? offsetFromPoint(ta, caretPoint.x, caretPoint.y) : null;
336
+ if (at === null) ta.select();
337
+ else ta.setSelectionRange(at, at);
338
+ // Entry point is read once per session.
339
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see above
340
+ }, []);
341
+
342
+ // A click anywhere outside the editor (or its formatting toolbar) commits.
343
+ useEffect(() => {
344
+ if (typeof document === 'undefined') return;
345
+ const onDown = (e: PointerEvent) => {
346
+ const t = e.target as Element | null;
347
+ const ta = ref.current;
348
+ if (!ta || (t && ta.contains(t))) return;
349
+ if (t?.closest?.('.dc-annot-ctx, [data-mediaref-player]')) return;
350
+ commit();
351
+ };
352
+ document.addEventListener('pointerdown', onDown, true);
353
+ return () => document.removeEventListener('pointerdown', onDown, true);
354
+ }, [commit]);
355
+
356
+ // Unmount without an explicit end (the element vanished, the canvas closed): keep the text.
357
+ useEffect(() => () => void commit(), [commit]);
358
+
359
+ // Draft: the text as typed so far, after a pause (Task 19).
360
+ useEffect(() => {
361
+ const onDraft = propsRef.current.onDraft;
362
+ if (!onDraft || guard.done) return;
363
+ const t = setTimeout(() => {
364
+ if (!guard.done) onDraft(stripListMarkers(valueRef.current, propsRef.current.list));
365
+ }, DRAFT_IDLE_MS);
366
+ return () => clearTimeout(t);
367
+ }, [value, guard]);
368
+
369
+ return (
370
+ <textarea
371
+ ref={ref}
372
+ // `dc-annot-editor` is chrome to every annotation pointer handler: a
373
+ // click or double-click in the editor is text editing, never a select or
374
+ // a drag of the element the editor sits in.
375
+ className={`${className} dc-annot-editor`}
376
+ data-annot-editor="1"
377
+ aria-label={ariaLabel}
378
+ value={value}
379
+ rows={1}
380
+ wrap={size.kind === 'fit-both' ? 'off' : 'soft'}
381
+ spellCheck
382
+ style={{ ...style, ...fmtStyle }}
383
+ onChange={(e) => setValue(e.target.value)}
384
+ onCompositionStart={() => ime.start()}
385
+ onCompositionEnd={() => {
386
+ ime.end();
387
+ setTimeout(() => ime.release(), 0);
388
+ }}
389
+ onBlur={(e) => {
390
+ const to = e.relatedTarget as Element | null;
391
+ if (to?.closest?.('.dc-annot-ctx')) return;
392
+ commit();
393
+ }}
394
+ onKeyDown={(e) => {
395
+ if (onFormatKey(e)) return;
396
+ const composing = ime.composing({
397
+ isComposing: e.nativeEvent.isComposing,
398
+ keyCode: e.keyCode,
399
+ });
400
+ const action = keyAction(
401
+ {
402
+ key: e.key,
403
+ shiftKey: e.shiftKey,
404
+ metaKey: e.metaKey,
405
+ ctrlKey: e.ctrlKey,
406
+ isComposing: e.nativeEvent.isComposing,
407
+ keyCode: e.keyCode,
408
+ },
409
+ composing,
410
+ singleLine
411
+ );
412
+ if (action === 'none') return;
413
+ e.preventDefault();
414
+ e.stopPropagation();
415
+ if (action === 'cancel') cancel();
416
+ else commit(action === 'commit-chain');
417
+ }}
418
+ />
419
+ );
420
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @file annotations/ui/text-session.ts — one text edit, as ops (Task 19)
3
+ * @scope apps/studio/annotations/ui/text-session.ts
4
+ * @purpose DDR-242 AD7 "draft persistence" + "remote changes while
5
+ * editing". Pure: builds the ops an edit session sends, React-free.
6
+ *
7
+ * A session remembers two texts:
8
+ * base — the slot's text when the editor opened. Undo goes
9
+ * back here (issue #106 C3: never to a stale value).
10
+ * lastSent — what this session last put on the board (a draft
11
+ * or the base). Every text op EXPECTS it, so the
12
+ * board merges a collaborator's concurrent typing
13
+ * 3-way against exactly what we last saw — expecting
14
+ * the base instead would make our own drafts look
15
+ * like a conflicting edit and drop theirs.
16
+ *
17
+ * Drafts: after ~600 ms of idle typing the editor reports its text;
18
+ * `draftOp` patches it so peers see typing land in about a second
19
+ * and a crash loses less than a second. Drafts never reach undo.
20
+ * The final commit is one undo record: base → final.
21
+ */
22
+
23
+ import type { Op } from '../ops.ts';
24
+ import type { AnnotationElement } from '../types.ts';
25
+
26
+ export type SlotKind = 'text' | 'label-record' | 'label-string';
27
+
28
+ export interface TextSession {
29
+ id: string;
30
+ kind: SlotKind;
31
+ base: string;
32
+ lastSent: string;
33
+ }
34
+
35
+ /** Where an element keeps its editable text, or null (no text slot). */
36
+ export function slotKindOf(el: Pick<AnnotationElement, 'type'>): SlotKind | null {
37
+ if (el.type === 'sticky' || el.type === 'text') return 'text';
38
+ if (el.type === 'shape') return 'label-record';
39
+ if (el.type === 'section') return 'label-string';
40
+ return null;
41
+ }
42
+
43
+ export function slotText(el: AnnotationElement, kind: SlotKind): string {
44
+ if (kind === 'text') return typeof el.text === 'string' ? el.text : '';
45
+ if (kind === 'label-string') return typeof el.label === 'string' ? el.label : '';
46
+ const label = el.label as { text?: unknown } | undefined;
47
+ return typeof label?.text === 'string' ? label.text : '';
48
+ }
49
+
50
+ export function openSession(el: AnnotationElement): TextSession | null {
51
+ const kind = slotKindOf(el);
52
+ if (!kind) return null;
53
+ const t = slotText(el, kind);
54
+ return { id: el.id, kind, base: t, lastSent: t };
55
+ }
56
+
57
+ function setOf(kind: SlotKind, current: AnnotationElement | undefined, text: string) {
58
+ if (kind === 'text') return { text };
59
+ if (kind === 'label-string') return { label: text };
60
+ // Full record: other label fields (size, colour, style) are kept even where
61
+ // the board resolves the patch without a merge.
62
+ const cur = (current?.label ?? {}) as Record<string, unknown>;
63
+ return { label: { ...cur, text } };
64
+ }
65
+
66
+ function expectOf(kind: SlotKind, text: string) {
67
+ if (kind === 'text') return { text };
68
+ if (kind === 'label-string') return { label: text };
69
+ return { label: { text } };
70
+ }
71
+
72
+ /** The patch a draft sends, or null when nothing changed since the last send. */
73
+ export function draftOp(
74
+ s: TextSession,
75
+ current: AnnotationElement | undefined,
76
+ text: string
77
+ ): Op | null {
78
+ if (text === s.lastSent || !current) return null;
79
+ return {
80
+ op: 'patch',
81
+ id: s.id,
82
+ set: setOf(s.kind, current, text),
83
+ expect: expectOf(s.kind, s.lastSent),
84
+ };
85
+ }
86
+
87
+ /** Record that `text` reached the board (after a draft or the commit was sent). */
88
+ export function markSent(s: TextSession, text: string): TextSession {
89
+ return { ...s, lastSent: text };
90
+ }
91
+
92
+ /**
93
+ * Re-aim the commit batch's text expectation at `lastSent`. The batch is a diff
94
+ * of the UI state (which may already hold a collaborator's merged text); the
95
+ * board must merge against what THIS session last sent.
96
+ */
97
+ export function aimCommitOps(ops: readonly Op[], s: TextSession): Op[] {
98
+ return ops.map((op) => {
99
+ if (op.op !== 'patch' || op.id !== s.id) return op;
100
+ const field = s.kind === 'text' ? 'text' : 'label';
101
+ if (!op.set || !(field in op.set)) return op;
102
+ const expect = { ...(op.expect ?? {}) } as Record<string, unknown>;
103
+ if (s.kind === 'label-record') {
104
+ const e = (expect.label ?? {}) as Record<string, unknown>;
105
+ expect.label = { ...e, text: s.lastSent };
106
+ } else {
107
+ expect[field] = s.lastSent;
108
+ }
109
+ return { ...op, expect };
110
+ });
111
+ }
112
+
113
+ /**
114
+ * A collaborator deleted the element while it was being edited and the user
115
+ * chose to keep their text: put it back, same id, with the typed text. A
116
+ * parent that is gone too is dropped — `world` carries the element's last
117
+ * world position for that case.
118
+ */
119
+ export function restoreOp(
120
+ s: TextSession,
121
+ lastKnown: AnnotationElement,
122
+ text: string,
123
+ parentExists: boolean,
124
+ world?: { x: number; y: number }
125
+ ): Op {
126
+ const el: Record<string, unknown> = { ...lastKnown, ...setOf(s.kind, lastKnown, text) };
127
+ if (el.parent !== undefined && !parentExists) {
128
+ delete el.parent;
129
+ if (world) {
130
+ el.x = world.x;
131
+ el.y = world.y;
132
+ }
133
+ }
134
+ return { op: 'put', el: el as AnnotationElement };
135
+ }
136
+
137
+ /** Did a board change move this session's text away from what it last sent? */
138
+ export function remotelyEdited(s: TextSession, current: AnnotationElement | undefined): boolean {
139
+ return !!current && slotText(current, s.kind) !== s.lastSent;
140
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file annotations/ui/text-style.ts — one box model for every text slot
3
+ * @scope apps/studio/annotations/ui/text-style.ts
4
+ * @purpose DDR-242 AD7: object text renders as HTML, and its display box and
5
+ * its editor (`<textarea>`) share ONE class and ONE inline style,
6
+ * so wrapping and line positions are identical by construction —
7
+ * no jump when an edit opens or commits. Every slot (sticky body,
8
+ * standalone text, shape label, section title) gets its style here.
9
+ * React-free apart from the CSSProperties type.
10
+ */
11
+
12
+ import type { CSSProperties } from 'react';
13
+
14
+ export const TEXT_FONT = 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)';
15
+
16
+ /** Line heights per slot — the values the v1 renderers used, so nothing moves. */
17
+ export const LINE_HEIGHT = {
18
+ sticky: 1.35,
19
+ label: 1.25,
20
+ text: 1.25,
21
+ } as const;
22
+
23
+ export type TextSlot = 'sticky' | 'label' | 'text' | 'title';
24
+ export type TextAlign = 'left' | 'center' | 'right';
25
+ export type ListStyle = 'bullet' | 'number';
26
+
27
+ export interface TextFormat {
28
+ fontSize: number;
29
+ color?: string;
30
+ bold?: boolean;
31
+ italic?: boolean;
32
+ strike?: boolean;
33
+ underline?: boolean;
34
+ align?: TextAlign;
35
+ }
36
+
37
+ export function textDecoration(strike?: boolean, underline?: boolean): string | undefined {
38
+ if (strike && underline) return 'line-through underline';
39
+ if (strike) return 'line-through';
40
+ if (underline) return 'underline';
41
+ return undefined;
42
+ }
43
+
44
+ /** Inline style shared by a slot's display block and its textarea. */
45
+ export function slotTextStyle(slot: TextSlot, f: TextFormat): CSSProperties {
46
+ const align = f.align ?? (slot === 'label' ? 'center' : 'left');
47
+ return {
48
+ fontFamily: TEXT_FONT,
49
+ fontSize: `${f.fontSize}px`,
50
+ lineHeight: slot === 'title' ? undefined : LINE_HEIGHT[slot],
51
+ fontWeight: f.bold ? 700 : undefined,
52
+ fontStyle: f.italic ? 'italic' : undefined,
53
+ textDecoration: textDecoration(f.strike, f.underline),
54
+ textAlign: align,
55
+ ...(f.color ? { color: f.color } : {}),
56
+ };
57
+ }
58
+
59
+ /** Standalone text is anchored at its `x`: left edge, centre or right edge by alignment (v1 semantics). */
60
+ export function anchorShift(align: TextAlign | undefined): string | undefined {
61
+ if (align === 'center') return 'translateX(-50%)';
62
+ if (align === 'right') return 'translateX(-100%)';
63
+ return undefined;
64
+ }
65
+
66
+ /** Render-only list markers (DDR-091): never stored in the text. */
67
+ export function withListMarkers(text: string, list?: ListStyle): string {
68
+ if (!list) return text;
69
+ return text
70
+ .split('\n')
71
+ .map((line, i) => (list === 'bullet' ? `• ${line}` : `${i + 1}. ${line}`))
72
+ .join('\n');
73
+ }
74
+
75
+ /**
76
+ * Inverse of {@link withListMarkers} for text typed in an editor that showed
77
+ * them. Lenient: any leading `N. ` goes, whatever number the user left there,
78
+ * so renumbering while editing round-trips.
79
+ */
80
+ export function stripListMarkers(text: string, list?: ListStyle): string {
81
+ if (!list) return text;
82
+ const re = list === 'bullet' ? /^• / : /^\d+\.\s/;
83
+ return text
84
+ .split('\n')
85
+ .map((line) => line.replace(re, ''))
86
+ .join('\n');
87
+ }
88
+
89
+ /**
90
+ * The stylesheet for the text layer. Display blocks and editors share
91
+ * `.dc-annot-text`; the textarea adds only what a form control needs to look
92
+ * like plain text (no border, no resize grip, inherited font).
93
+ */
94
+ export const TEXT_LAYER_CSS = `
95
+ .dc-annot-scene { position: absolute; left: 0; top: 0; width: 0; height: 0; overflow: visible; pointer-events: none; }
96
+ .dc-annot-el { position: absolute; pointer-events: none; }
97
+ .dc-annot-el > svg.dc-annot-geo { position: absolute; left: 0; top: 0; overflow: visible; pointer-events: none; }
98
+ .dc-annot-text { box-sizing: border-box; white-space: pre-wrap; overflow-wrap: anywhere; margin: 0; }
99
+ .dc-annot-text--nowrap { white-space: pre; overflow-wrap: normal; }
100
+ textarea.dc-annot-text {
101
+ display: block; resize: none; border: 0; outline: none; background: transparent; pointer-events: auto;
102
+ padding: 0; overflow: hidden; color: inherit; cursor: text;
103
+ caret-color: var(--maude-hud-accent, #4a63e7);
104
+ -webkit-appearance: none; appearance: none;
105
+ }
106
+ .dc-annot-label { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; padding: 0 8px; box-sizing: border-box; }
107
+ .dc-annot-label > .dc-annot-text { width: 100%; }
108
+ .dc-annot-chip { position: absolute; left: 0; box-sizing: border-box; border-radius: 5px; white-space: nowrap; overflow: visible; }
109
+ .dc-annot-placeholder { position: absolute; inset: 0; border: 1.5px dashed rgba(127,127,127,0.7); border-radius: 6px;
110
+ display: flex; align-items: center; justify-content: center; font: 11px ${TEXT_FONT}; color: rgba(127,127,127,0.9); }
111
+ `.trim();