@pilotiq/pilotiq 0.8.2 → 0.9.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/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +165 -0
  3. package/dist/Resource.d.ts +39 -0
  4. package/dist/Resource.d.ts.map +1 -1
  5. package/dist/Resource.js +30 -0
  6. package/dist/Resource.js.map +1 -1
  7. package/dist/pageData/navigation.d.ts +17 -1
  8. package/dist/pageData/navigation.d.ts.map +1 -1
  9. package/dist/pageData/navigation.js +14 -0
  10. package/dist/pageData/navigation.js.map +1 -1
  11. package/dist/react/AppShell.d.ts +5 -0
  12. package/dist/react/AppShell.d.ts.map +1 -1
  13. package/dist/react/AppShell.js +1 -1
  14. package/dist/react/AppShell.js.map +1 -1
  15. package/dist/react/FormCollabBindingRegistry.d.ts +71 -1
  16. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  17. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  18. package/dist/react/FormStateContext.d.ts +17 -0
  19. package/dist/react/FormStateContext.d.ts.map +1 -1
  20. package/dist/react/FormStateContext.js +44 -3
  21. package/dist/react/FormStateContext.js.map +1 -1
  22. package/dist/react/RecordWrapperGate.d.ts +19 -6
  23. package/dist/react/RecordWrapperGate.d.ts.map +1 -1
  24. package/dist/react/RecordWrapperGate.js +18 -8
  25. package/dist/react/RecordWrapperGate.js.map +1 -1
  26. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  27. package/dist/react/fields/MarkdownInput.js +105 -3
  28. package/dist/react/fields/MarkdownInput.js.map +1 -1
  29. package/dist/react/fields/TextLikeInput.d.ts +10 -0
  30. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  31. package/dist/react/fields/TextLikeInput.js +179 -0
  32. package/dist/react/fields/TextLikeInput.js.map +1 -1
  33. package/dist/react/fields/textDelta.d.ts +44 -0
  34. package/dist/react/fields/textDelta.d.ts.map +1 -0
  35. package/dist/react/fields/textDelta.js +80 -0
  36. package/dist/react/fields/textDelta.js.map +1 -0
  37. package/dist/react/index.d.ts +2 -2
  38. package/dist/react/index.d.ts.map +1 -1
  39. package/dist/react/index.js +1 -1
  40. package/dist/react/index.js.map +1 -1
  41. package/dist/react/parseRecordEditUrl.d.ts +33 -9
  42. package/dist/react/parseRecordEditUrl.d.ts.map +1 -1
  43. package/dist/react/parseRecordEditUrl.js +40 -2
  44. package/dist/react/parseRecordEditUrl.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/Resource.test.ts +44 -0
  47. package/src/Resource.ts +58 -0
  48. package/src/pageData/navigation.ts +32 -1
  49. package/src/pageData.test.ts +36 -0
  50. package/src/react/AppShell.tsx +6 -1
  51. package/src/react/FormCollabBindingRegistry.ts +63 -1
  52. package/src/react/FormStateContext.tsx +62 -3
  53. package/src/react/RecordWrapperGate.tsx +26 -8
  54. package/src/react/fields/MarkdownInput.tsx +100 -3
  55. package/src/react/fields/TextLikeInput.tsx +203 -1
  56. package/src/react/fields/textDelta.test.ts +141 -0
  57. package/src/react/fields/textDelta.ts +86 -0
  58. package/src/react/index.ts +9 -1
  59. package/src/react/parseRecordEditUrl.test.ts +48 -1
  60. package/src/react/parseRecordEditUrl.ts +52 -13
@@ -1,4 +1,4 @@
1
- import React, { useMemo, useRef, useState } from 'react'
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { marked } from 'marked'
3
3
  import {
4
4
  BoldIcon, ItalicIcon, StrikethroughIcon, LinkIcon,
@@ -8,6 +8,7 @@ import {
8
8
  import { useFieldState } from '../FormStateContext.js'
9
9
  import { useToast } from '../Toaster.js'
10
10
  import { Button } from '../ui/button.js'
11
+ import { computeDelta, preserveCursor } from './textDelta.js'
11
12
 
12
13
  type ToolbarButton =
13
14
  | 'bold' | 'italic' | 'strike' | 'link'
@@ -47,14 +48,90 @@ export function MarkdownInput({
47
48
  const fs = useFieldState(name)
48
49
  const { notify } = useToast()
49
50
  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
51
+ // Phase F.6 — IME composition gate. Set between `compositionstart` /
52
+ // `compositionend`; the textarea's onChange skips `applyDelta` while
53
+ // composing so intermediate chars don't emit ops. Lives at the
54
+ // component scope so the onChange and composition handlers share it.
55
+ const isComposingRef = useRef<boolean>(false)
50
56
 
51
57
  const initial = useMemo(() => stringValue(defaultValue), [])
52
58
  const [localValue, setLocalValue] = useState<string>(initial)
53
59
  const [tab, setTab] = useState<'write' | 'preview'>('write')
54
60
  const [busy, setBusy] = useState(false)
55
61
 
56
- const value = fs.controlled ? stringValue(fs.value) : localValue
62
+ // Phase F.6 when a `<RecordCollabRoom>` is mounted and the field has
63
+ // a `TextBinding`, the textarea is bound to a `Y.Text` and edits emit
64
+ // `TextDelta`s. Mirrors the architecture in `TextLikeInput.tsx` but
65
+ // wired in-line because MarkdownInput has its own toolbar + Preview
66
+ // tab that also need to flow through the binding.
67
+ const binding = fs.textBinding
68
+ const [boundValue, setBoundValue] = useState<string>(() => binding?.read() ?? initial)
69
+ const boundValueRef = useRef<string>(boundValue)
70
+ useEffect(() => { boundValueRef.current = boundValue }, [boundValue])
71
+
72
+ // On binding swap: read current Y.Text state. If non-empty, lift it
73
+ // into local + form-map state. If empty (no peer has typed yet), leave
74
+ // the SSR-default-derived `boundValue` showing — first edit will
75
+ // emit a replace-from-empty delta that atomically populates Y.Text.
76
+ // No client-side seed: Y.Text isn't safe to seed under concurrent
77
+ // first-mounters (see @pilotiq-pro/collab `formCollabBinding.ts`).
78
+ useEffect(() => {
79
+ if (!binding) return
80
+ const next = binding.read()
81
+ if (next.length > 0) {
82
+ setBoundValue(next)
83
+ boundValueRef.current = next
84
+ fs.setValue(next)
85
+ }
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, [binding])
88
+
89
+ // Subscribe to remote changes. Local-echoes are filtered by the
90
+ // `next === prev` guard. Cursor preserved via the same heuristic
91
+ // used in `TextLikeInput.BoundTextInput`.
92
+ useEffect(() => {
93
+ if (!binding) return
94
+ return binding.observe((next) => {
95
+ const prev = boundValueRef.current
96
+ if (next === prev) return
97
+ const ta = textareaRef.current
98
+ const cursor = ta?.selectionStart ?? next.length
99
+ const restored = preserveCursor(prev, next, cursor)
100
+ setBoundValue(next)
101
+ boundValueRef.current = next
102
+ fs.setValue(next)
103
+ requestAnimationFrame(() => {
104
+ if (!ta) return
105
+ if (document.activeElement !== ta) return
106
+ try { ta.setSelectionRange(restored, restored) } catch { /* defensive */ }
107
+ })
108
+ })
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, [binding])
111
+
112
+ const value = binding
113
+ ? boundValue
114
+ : (fs.controlled ? stringValue(fs.value) : localValue)
115
+
57
116
  const setValue = (next: string): void => {
117
+ if (binding) {
118
+ // Compute against current Y.Text contents (not the local ref) so:
119
+ // - first edit against empty Y.Text → `insert@0 <whole>` atomic
120
+ // populate (no separate seed op needed);
121
+ // - after a remote-applied update or server-resolve replace, the
122
+ // delta reflects the actual current shared state, not stale
123
+ // local bookkeeping.
124
+ const before = binding.read()
125
+ if (next !== before) {
126
+ const delta = computeDelta(before, next)
127
+ if (delta) binding.applyDelta(delta)
128
+ setBoundValue(next)
129
+ boundValueRef.current = next
130
+ }
131
+ fs.setValue(next)
132
+ fs.triggerLive(next)
133
+ return
134
+ }
58
135
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
59
136
  else { setLocalValue(next); fs.triggerLive(next) }
60
137
  }
@@ -285,7 +362,27 @@ export function MarkdownInput({
285
362
  placeholder={placeholder}
286
363
  disabled={disabled}
287
364
  {...(fs.controlled
288
- ? { value, onChange: (e) => setValue(e.target.value) }
365
+ ? {
366
+ value,
367
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => {
368
+ // Phase F.6 — when the binding is active and the user
369
+ // is mid-IME, paint locally and hold the delta until
370
+ // compositionend so we never emit ops for the
371
+ // intermediate composing chars.
372
+ if (binding && isComposingRef.current) {
373
+ setBoundValue(e.target.value)
374
+ return
375
+ }
376
+ setValue(e.target.value)
377
+ },
378
+ ...(binding ? {
379
+ onCompositionStart: () => { isComposingRef.current = true },
380
+ onCompositionEnd: (e: React.CompositionEvent<HTMLTextAreaElement>) => {
381
+ isComposingRef.current = false
382
+ setValue(e.currentTarget.value)
383
+ },
384
+ } : {}),
385
+ }
289
386
  : { defaultValue: initial, onChange: (e) => setLocalValue(e.target.value) })}
290
387
  onPaste={onPaste}
291
388
  onKeyDown={onKeyDown}
@@ -1,8 +1,10 @@
1
- import React from 'react'
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import type { ElementMeta } from '../../schema/Element.js'
3
+ import type { TextBinding } from '../FormCollabBindingRegistry.js'
3
4
  import { useFieldState } from '../FormStateContext.js'
4
5
  import { Input } from '../ui/input.js'
5
6
  import { Textarea } from '../ui/textarea.js'
7
+ import { computeDelta, preserveCursor } from './textDelta.js'
6
8
 
7
9
  /**
8
10
  * Bridge between controlled (FormStateProvider) and uncontrolled
@@ -10,6 +12,16 @@ import { Textarea } from '../ui/textarea.js'
10
12
  * `live()` fields, the input is bound to the context's values map and
11
13
  * fires the live trigger on change/blur according to the field's `live`
12
14
  * config. Outside a controlled form, falls back to plain `defaultValue`.
15
+ *
16
+ * **Phase F.6 — character-level CRDT branch.** When a `<RecordCollabRoom>`
17
+ * is mounted up-tree AND `@pilotiq-pro/collab`'s binding registered a
18
+ * `TextBinding` for this field (text-shaped fieldType + `.collab() !== false`),
19
+ * the input takes the `BoundTextInput` path: edits emit `TextDelta`s to
20
+ * the binding's `Y.Text`, remote changes flow back via `observe`, and
21
+ * cursor position survives both. The legacy whole-string LWW path
22
+ * still runs for non-text fields, non-collab forms, and masked inputs
23
+ * (mask + character-level CRDT is incompatible — peers would see raw
24
+ * keystrokes desynced from the rendered mask).
13
25
  */
14
26
  export function TextLikeInput({
15
27
  el, name, common, type, extraProps, multiline, applyMask,
@@ -33,6 +45,28 @@ export function TextLikeInput({
33
45
  const onBlurMode = liveOpts.onBlur === true
34
46
  const mask = applyMask ?? identity
35
47
 
48
+ // Phase F.6 — character-level CRDT path. Masking is mutually exclusive
49
+ // with character-level CRDT (peers would see raw keystrokes diverged
50
+ // from the local mask render); masked fields fall through to LWW.
51
+ // We read the mask from the field meta directly — `applyMask` is a
52
+ // `useCallback`-wrapped fn that's *always* defined (identity when no
53
+ // mask), so its truthiness can't gate the branch.
54
+ const hasMask = typeof el['mask'] === 'string'
55
+ if (fs.textBinding && !hasMask) {
56
+ return (
57
+ <BoundTextInput
58
+ binding={fs.textBinding}
59
+ name={name}
60
+ triggerLive={fs.triggerLive}
61
+ onBlurMode={onBlurMode}
62
+ common={common}
63
+ extraProps={extraProps}
64
+ type={type}
65
+ multiline={multiline}
66
+ />
67
+ )
68
+ }
69
+
36
70
  if (fs.controlled) {
37
71
  const ctxValue = fs.value !== undefined && fs.value !== null ? String(fs.value) : ''
38
72
  const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
@@ -71,4 +105,172 @@ export function TextLikeInput({
71
105
  return <Input {...(common as React.ComponentProps<typeof Input>)} type={type} {...extraProps} />
72
106
  }
73
107
 
108
+ /**
109
+ * Phase F.6 — CRDT-bound text input. Owns its own controlled state
110
+ * because the binding's `Y.Text` is the source of truth (not the
111
+ * form's `values` map). Mirrors every committed value back into the
112
+ * form context via `fs.setValue` so submission / live re-resolve see
113
+ * the latest string.
114
+ *
115
+ * Lifecycle:
116
+ * - Mount: seed local state from `binding.read()`; mirror it into
117
+ * the form's `values` map.
118
+ * - Local edit: compute a `TextDelta` (insert / delete / replace)
119
+ * from the before/after strings and `applyDelta` to the binding.
120
+ * Eagerly update local state in the same React render so the
121
+ * controlled input doesn't lag the keystroke.
122
+ * - Remote edit: `binding.observe` fires with the post-change
123
+ * string; we replace local state and best-effort preserve the
124
+ * local cursor via `preserveCursor`. The local-echo of our own
125
+ * `applyDelta` is collapsed by the value-equality check.
126
+ * - IME composition: `applyDelta` is deferred to `compositionend`
127
+ * so the binding never sees intermediate composing chars (which
128
+ * would emit one delta per keystroke and confuse downstream
129
+ * observers).
130
+ */
131
+ function BoundTextInput({
132
+ binding, name, triggerLive, onBlurMode, common, extraProps, type, multiline,
133
+ }: {
134
+ binding: TextBinding
135
+ name: string
136
+ triggerLive: (valueOverride?: unknown) => void
137
+ onBlurMode: boolean
138
+ common: Record<string, unknown>
139
+ extraProps: Record<string, unknown>
140
+ type: string
141
+ multiline: boolean
142
+ }): React.ReactElement {
143
+ const fs = useFieldState(name)
144
+ // SSR-rendered default. Captured once at mount; used as display
145
+ // fallback while the room's `Y.Text` is still empty (the seed race
146
+ // for Y.Text isn't safe across concurrent first-mounters, so no peer
147
+ // populates it client-side — see `@pilotiq-pro/collab` for the
148
+ // rationale). First user edit emits a replace-from-empty delta that
149
+ // atomically lifts the displayed value into the CRDT.
150
+ // eslint-disable-next-line react-hooks/exhaustive-deps
151
+ const fallback = useMemo(() => stringValue(fs.value), [])
152
+ const [value, setValueLocal] = useState<string>(() => binding.read() || fallback)
153
+ const valueRef = useRef<string>(value)
154
+ const isComposing = useRef<boolean>(false)
155
+ const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null)
156
+
157
+ useEffect(() => { valueRef.current = value }, [value])
158
+
159
+ // Stable ref to the form-mirror writer so the observer effect below
160
+ // doesn't tear down on every render (fs.setValue is a fresh arrow on
161
+ // every useFieldState call).
162
+ const mirrorRef = useRef<(v: string) => void>(() => {})
163
+ useEffect(() => {
164
+ mirrorRef.current = (v: string): void => { fs.setValue(v) }
165
+ })
166
+
167
+ // On mount / binding swap: read the binding's current state. If
168
+ // non-empty (i.e. someone else has already typed), display it and
169
+ // mirror into the form values map. If empty, leave the fallback
170
+ // showing — no client-side seed (see file-header comment).
171
+ useEffect(() => {
172
+ const initial = binding.read()
173
+ if (initial.length > 0) {
174
+ setValueLocal(initial)
175
+ valueRef.current = initial
176
+ mirrorRef.current(initial)
177
+ }
178
+ }, [binding])
179
+
180
+ // Subscribe to text-CRDT changes. Yjs fires this for BOTH local and
181
+ // remote transactions — local echoes are collapsed by the
182
+ // `next === prev` guard.
183
+ useEffect(() => {
184
+ const unsubscribe = binding.observe((next) => {
185
+ const prev = valueRef.current
186
+ if (next === prev) return
187
+ const el = inputRef.current
188
+ const cursor = el?.selectionStart ?? next.length
189
+ const restored = preserveCursor(prev, next, cursor)
190
+ setValueLocal(next)
191
+ valueRef.current = next
192
+ mirrorRef.current(next)
193
+ // Defer cursor restore until after React commits. Only reapply
194
+ // when the input is still focused — yanking the selection on a
195
+ // blurred field would steal focus across the page.
196
+ requestAnimationFrame(() => {
197
+ if (!el) return
198
+ if (document.activeElement !== el) return
199
+ try { el.setSelectionRange(restored, restored) } catch { /* setSelectionRange unsupported on some input types — defensive */ }
200
+ })
201
+ })
202
+ return unsubscribe
203
+ }, [binding])
204
+
205
+ const commitDelta = useCallback((after: string): void => {
206
+ // Compute the delta against the binding's *current* Y.Text contents
207
+ // — not the renderer's `before` ref. The two can diverge in three
208
+ // cases that all converge correctly under this approach:
209
+ // 1. First edit when Y.Text is empty: delta = `insert@0 <whole>`,
210
+ // which atomically lifts the displayed fallback into the CRDT
211
+ // without a separate seed op.
212
+ // 2. After a remote-applied update: Y.Text holds the peer's value;
213
+ // computing against it avoids "ghost" deltas that re-emit ops
214
+ // against a stale local ref.
215
+ // 3. After a server-resolve `triggerLive` replace: same as (2).
216
+ const before = binding.read()
217
+ if (after === before) return
218
+ const delta = computeDelta(before, after)
219
+ if (!delta) return
220
+ binding.applyDelta(delta)
221
+ // Eager local + form-map update so the controlled input doesn't
222
+ // wait on the observer echo to render the new keystroke. Observer
223
+ // will fire with the same string and short-circuit via the equality
224
+ // check above.
225
+ setValueLocal(after)
226
+ valueRef.current = after
227
+ mirrorRef.current(after)
228
+ if (!onBlurMode) triggerLive(after)
229
+ }, [binding, onBlurMode, triggerLive])
230
+
231
+ const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
232
+ if (isComposing.current) {
233
+ // IME mid-composition — paint locally, hold the delta until commit.
234
+ setValueLocal(e.target.value)
235
+ return
236
+ }
237
+ commitDelta(e.target.value)
238
+ }
239
+
240
+ const onCompositionStart = (): void => { isComposing.current = true }
241
+ const onCompositionEnd = (e: React.CompositionEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
242
+ isComposing.current = false
243
+ commitDelta(e.currentTarget.value)
244
+ }
245
+
246
+ const onBlur = (): void => {
247
+ if (onBlurMode) triggerLive(valueRef.current)
248
+ }
249
+
250
+ const setRef = (el: HTMLInputElement | HTMLTextAreaElement | null): void => {
251
+ inputRef.current = el
252
+ }
253
+
254
+ const props = {
255
+ ...common,
256
+ ...extraProps,
257
+ defaultValue: undefined,
258
+ value,
259
+ onChange,
260
+ onBlur,
261
+ onCompositionStart,
262
+ onCompositionEnd,
263
+ ref: setRef,
264
+ }
265
+
266
+ if (multiline) return <Textarea {...(props as React.ComponentProps<typeof Textarea>)} />
267
+ return <Input {...(props as React.ComponentProps<typeof Input>)} type={type} />
268
+ }
269
+
74
270
  function identity(v: string): string { return v }
271
+
272
+ function stringValue(v: unknown): string {
273
+ if (v === undefined || v === null) return ''
274
+ if (typeof v === 'string') return v
275
+ return String(v)
276
+ }
@@ -0,0 +1,141 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { computeDelta, preserveCursor } from './textDelta.js'
5
+
6
+ describe('computeDelta — string-diff to TextDelta', () => {
7
+ it('returns null for identical strings', () => {
8
+ assert.equal(computeDelta('hello', 'hello'), null)
9
+ assert.equal(computeDelta('', ''), null)
10
+ })
11
+
12
+ it('emits insert when text is appended', () => {
13
+ assert.deepEqual(
14
+ computeDelta('hello', 'hello!'),
15
+ { kind: 'insert', index: 5, text: '!' },
16
+ )
17
+ })
18
+
19
+ it('emits insert when text is prepended', () => {
20
+ assert.deepEqual(
21
+ computeDelta('world', 'hello world'),
22
+ { kind: 'insert', index: 0, text: 'hello ' },
23
+ )
24
+ })
25
+
26
+ it('emits insert when text is spliced mid-string', () => {
27
+ // Inserting an 'l' to make 'helo' → 'hello'. The longest common
28
+ // prefix is 'hel' (3 chars — before[2]='l' and after[2]='l' both
29
+ // match), so the insertion lands at index 3. Either interpretation
30
+ // (index 2 or index 3) produces the same CRDT result; the diff
31
+ // picks the rightmost feasible point deterministically.
32
+ assert.deepEqual(
33
+ computeDelta('helo', 'hello'),
34
+ { kind: 'insert', index: 3, text: 'l' },
35
+ )
36
+ })
37
+
38
+ it('emits delete when a trailing run is removed', () => {
39
+ assert.deepEqual(
40
+ computeDelta('hello!', 'hello'),
41
+ { kind: 'delete', index: 5, length: 1 },
42
+ )
43
+ })
44
+
45
+ it('emits delete when a leading run is removed', () => {
46
+ assert.deepEqual(
47
+ computeDelta('hello world', 'world'),
48
+ { kind: 'delete', index: 0, length: 6 },
49
+ )
50
+ })
51
+
52
+ it('emits delete when a mid-string run is removed', () => {
53
+ assert.deepEqual(
54
+ computeDelta('hello', 'hlo'),
55
+ { kind: 'delete', index: 1, length: 2 },
56
+ )
57
+ })
58
+
59
+ it('emits replace when a mid-string selection is swapped', () => {
60
+ assert.deepEqual(
61
+ computeDelta('hello world', 'hello pilot'),
62
+ { kind: 'replace', from: 6, to: 11, text: 'pilot' },
63
+ )
64
+ })
65
+
66
+ it('emits replace when the whole string is swapped', () => {
67
+ assert.deepEqual(
68
+ computeDelta('foo', 'bar'),
69
+ { kind: 'replace', from: 0, to: 3, text: 'bar' },
70
+ )
71
+ })
72
+
73
+ it('emits insert when growing from empty', () => {
74
+ assert.deepEqual(
75
+ computeDelta('', 'a'),
76
+ { kind: 'insert', index: 0, text: 'a' },
77
+ )
78
+ })
79
+
80
+ it('emits delete when shrinking to empty', () => {
81
+ assert.deepEqual(
82
+ computeDelta('abc', ''),
83
+ { kind: 'delete', index: 0, length: 3 },
84
+ )
85
+ })
86
+
87
+ it('handles repeated-char shrink without prefix/suffix overlap', () => {
88
+ // 'aaa' → 'aa' — the prefix walk could greedily eat all 2 chars from
89
+ // the after side; the suffix cap must stop suffix at 2 so beforeMid
90
+ // is 'a' (length 1) instead of '' (length 0, identity).
91
+ assert.deepEqual(
92
+ computeDelta('aaa', 'aa'),
93
+ { kind: 'delete', index: 2, length: 1 },
94
+ )
95
+ })
96
+ })
97
+
98
+ describe('preserveCursor — anchor across remote edits', () => {
99
+ it('returns input cursor when strings are identical', () => {
100
+ assert.equal(preserveCursor('hello', 'hello', 3), 3)
101
+ })
102
+
103
+ it('leaves cursor untouched when edit lands AFTER cursor', () => {
104
+ // Cursor at index 2 ('he|llo'); remote appends ' world'. Edit prefix
105
+ // length is 5, cursor 2 ≤ prefix → no shift.
106
+ assert.equal(preserveCursor('hello', 'hello world', 2), 2)
107
+ })
108
+
109
+ it('shifts cursor when edit lands BEFORE cursor', () => {
110
+ // Cursor at 5 ('hello|'); remote prepends 'XX '. The common prefix
111
+ // is empty, so cursor > prefix → shift by (8 − 5) = 3, landing at
112
+ // 8 (the end of the new string, same logical position as before).
113
+ assert.equal(preserveCursor('hello', 'XX hello', 5), 8)
114
+ })
115
+
116
+ it('lands at end-of-string for non-contiguous edits (heuristic limit)', () => {
117
+ // Both-sides insertion ('hello' → 'X hello world') flattens into a
118
+ // single full-string `replace` at the diff layer because the prefix
119
+ // and suffix walks find no common ground. Cursor lands at the end
120
+ // of the new string — imperfect for this case but harmless. A
121
+ // future v2 using Yjs `RelativePosition` would land it at 7
122
+ // (just after the original 'hello' substring).
123
+ assert.equal(preserveCursor('hello', 'X hello world', 5), 13)
124
+ })
125
+
126
+ it('clamps cursor when remote deletes around the cursor', () => {
127
+ // Cursor at 5 ('hello|world'); remote deletes 'hello'. Prefix is 0,
128
+ // delta is -5 → shifted to 0.
129
+ assert.equal(preserveCursor('helloworld', 'world', 5), 0)
130
+ })
131
+
132
+ it('never returns a negative cursor', () => {
133
+ assert.equal(preserveCursor('abcdef', '', 3), 0)
134
+ })
135
+
136
+ it('never returns a cursor past the new length', () => {
137
+ // Defensive — caller might pass a stale cursor longer than the new
138
+ // string. Clamp to new bounds.
139
+ assert.equal(preserveCursor('hello', 'hi', 10), 2)
140
+ })
141
+ })
@@ -0,0 +1,86 @@
1
+ import type { TextDelta } from '../FormCollabBindingRegistry.js'
2
+
3
+ /**
4
+ * Phase F.6 — derive a single character-level edit op from two strings.
5
+ *
6
+ * Strategy: find the longest common prefix and suffix between `before`
7
+ * and `after`; whatever's left in the middle is the changed region.
8
+ *
9
+ * - middle-after empty + middle-before non-empty → `delete`
10
+ * - middle-before empty + middle-after non-empty → `insert`
11
+ * - both non-empty → `replace`
12
+ * - both empty (identical strings) → `null`
13
+ *
14
+ * This correctly handles the common edit shapes: single-key insert,
15
+ * single-key backspace, multi-char paste replacing a selection, IME
16
+ * commits, accent-key composition. It does NOT preserve user intent
17
+ * when the same character appears at multiple positions and the edit
18
+ * could be attributed to either occurrence — Yjs's per-character
19
+ * identity makes that distinction lossy at the string-diff layer
20
+ * (the `Y.Text` itself maintains item identity internally). For v1
21
+ * we accept the ambiguity; the CRDT semantics still converge.
22
+ */
23
+ export function computeDelta(before: string, after: string): TextDelta | null {
24
+ if (before === after) return null
25
+
26
+ let prefix = 0
27
+ const minLen = Math.min(before.length, after.length)
28
+ while (prefix < minLen && before[prefix] === after[prefix]) prefix++
29
+
30
+ // Walk back from each end, capped so suffix can't overlap the prefix
31
+ // on either side. Without the cap, identical strings of repeated
32
+ // chars (e.g. 'aaa' → 'aa') would consume the same byte from both
33
+ // directions and produce an empty middle on both sides.
34
+ let suffix = 0
35
+ const maxSuffix = Math.min(before.length - prefix, after.length - prefix)
36
+ while (
37
+ suffix < maxSuffix &&
38
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
39
+ ) {
40
+ suffix++
41
+ }
42
+
43
+ const beforeMid = before.slice(prefix, before.length - suffix)
44
+ const afterMid = after.slice(prefix, after.length - suffix)
45
+
46
+ if (beforeMid.length === 0 && afterMid.length > 0) {
47
+ return { kind: 'insert', index: prefix, text: afterMid }
48
+ }
49
+ if (afterMid.length === 0 && beforeMid.length > 0) {
50
+ return { kind: 'delete', index: prefix, length: beforeMid.length }
51
+ }
52
+ return { kind: 'replace', from: prefix, to: prefix + beforeMid.length, text: afterMid }
53
+ }
54
+
55
+ /**
56
+ * Phase F.6 — best-effort cursor anchor across a remote-applied edit.
57
+ *
58
+ * - Edit landed AFTER cursor (cursor inside the common prefix) → keep
59
+ * cursor where it is.
60
+ * - Edit landed BEFORE cursor → shift
61
+ * cursor by `after.length − before.length` so its character-offset
62
+ * into the post-edit string matches its pre-edit anchor.
63
+ * - Edit landed OVERLAPPING the cursor → cursor
64
+ * ends up at the boundary between the changed region and the
65
+ * unchanged suffix (which `Math.max(0, cursor + delta)` produces
66
+ * naturally and clamps to the new bounds).
67
+ *
68
+ * This is a heuristic, not a Yjs `RelativePosition`. Two peers typing
69
+ * at the exact same insertion point can still see a one-character cursor
70
+ * twitch on the remote-mirror side; v2 (in-input remote carets) would
71
+ * upgrade to relative positions if/when a consumer asks. Native input
72
+ * cursors are clamped to `[0, after.length]` by every browser, so this
73
+ * function does the same to avoid `setSelectionRange` throwing.
74
+ */
75
+ export function preserveCursor(before: string, after: string, cursor: number): number {
76
+ if (before === after) return cursor
77
+
78
+ let prefix = 0
79
+ const minLen = Math.min(before.length, after.length)
80
+ while (prefix < minLen && before[prefix] === after[prefix]) prefix++
81
+
82
+ if (cursor <= prefix) return Math.min(cursor, after.length)
83
+
84
+ const delta = after.length - before.length
85
+ return Math.max(0, Math.min(after.length, cursor + delta))
86
+ }
@@ -49,6 +49,8 @@ export {
49
49
  type FormCollabBinding,
50
50
  type FormCollabBindingFactory,
51
51
  type FormCollabBindingFactoryArgs,
52
+ type TextBinding,
53
+ type TextDelta,
52
54
  } from './FormCollabBindingRegistry.js'
53
55
  export {
54
56
  registerFieldPresenceComponent,
@@ -70,7 +72,13 @@ export {
70
72
  RecordWrapperGate,
71
73
  type RecordWrapperGateProps,
72
74
  } from './RecordWrapperGate.js'
73
- export { parseRecordEditUrl, type RecordEditIdentity } from './parseRecordEditUrl.js'
75
+ export {
76
+ parseRecordPageUrl,
77
+ parseRecordEditUrl,
78
+ type RecordPageIdentity,
79
+ type RecordPageRole,
80
+ type RecordEditIdentity,
81
+ } from './parseRecordEditUrl.js'
74
82
  export {
75
83
  registerWidgetRenderer,
76
84
  getWidgetRenderer,
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { parseRecordEditUrl } from './parseRecordEditUrl.js'
3
+ import { parseRecordEditUrl, parseRecordPageUrl } from './parseRecordEditUrl.js'
4
4
 
5
5
  test('parseRecordEditUrl: bare resource edit', () => {
6
6
  assert.deepEqual(
@@ -73,3 +73,50 @@ test('parseRecordEditUrl: slug-only edit (no record id) returns null', () => {
73
73
  // slice. Defensive: reject when slugParts is empty.
74
74
  assert.equal(parseRecordEditUrl('/admin/edit', '/admin'), null)
75
75
  })
76
+
77
+ // ─── parseRecordPageUrl (role-aware) ─────────────────────────
78
+
79
+ test('parseRecordPageUrl: edit URL returns role=edit', () => {
80
+ assert.deepEqual(
81
+ parseRecordPageUrl('/admin/articles/123/edit', '/admin'),
82
+ { resourceSlug: 'articles', recordId: '123', role: 'edit' },
83
+ )
84
+ })
85
+
86
+ test('parseRecordPageUrl: view URL returns role=view', () => {
87
+ assert.deepEqual(
88
+ parseRecordPageUrl('/admin/articles/123/view', '/admin'),
89
+ { resourceSlug: 'articles', recordId: '123', role: 'view' },
90
+ )
91
+ })
92
+
93
+ test('parseRecordPageUrl: cluster-prefixed view URL', () => {
94
+ assert.deepEqual(
95
+ parseRecordPageUrl('/admin/blog/articles/123/view', '/admin'),
96
+ { resourceSlug: 'blog/articles', recordId: '123', role: 'view' },
97
+ )
98
+ })
99
+
100
+ test('parseRecordPageUrl: terminal token other than edit|view returns null', () => {
101
+ // 'delete' / 'restore' / 'force-delete' are POST handlers, not pages.
102
+ assert.equal(parseRecordPageUrl('/admin/articles/123/delete', '/admin'), null)
103
+ assert.equal(parseRecordPageUrl('/admin/articles/123/restore', '/admin'), null)
104
+ // Custom record sub-pages also fall through here — they have their own
105
+ // gate path (not record-bound for collab purposes in v1).
106
+ assert.equal(parseRecordPageUrl('/admin/articles/123/history', '/admin'), null)
107
+ })
108
+
109
+ test('parseRecordPageUrl: view URL in nested-relation form', () => {
110
+ assert.deepEqual(
111
+ parseRecordPageUrl('/admin/articles/123/comments/456/view', '/admin'),
112
+ { resourceSlug: 'articles/123/comments', recordId: '456', role: 'view' },
113
+ )
114
+ })
115
+
116
+ // ─── Legacy alias: parseRecordEditUrl filters view URLs ──────
117
+
118
+ test('parseRecordEditUrl: view URL returns null (back-compat: edit-only)', () => {
119
+ // A consumer still calling the legacy `parseRecordEditUrl` should
120
+ // continue to see view URLs filtered out — only edit URLs round-trip.
121
+ assert.equal(parseRecordEditUrl('/admin/articles/123/view', '/admin'), null)
122
+ })