@elytracms/studio 0.0.94 → 0.0.96

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 (164) hide show
  1. package/convex/asset-alt-migration.test.ts +2 -2
  2. package/convex/asset-meta-patch.test.ts +218 -0
  3. package/convex/asset-replace-delete-race.test.ts +127 -0
  4. package/convex/assets.ts +27 -2
  5. package/convex/cms.ts +124 -8
  6. package/convex/delivery-redirects.test.ts +167 -0
  7. package/convex/delivery.ts +15 -5
  8. package/convex/rail-write-parity.test.ts +6 -6
  9. package/convex/redirects.ts +1 -1
  10. package/convex/schema.ts +6 -0
  11. package/convex/strict-reference-race.test.ts +139 -0
  12. package/dist/addon.d.ts +1 -1
  13. package/dist/addon.js.map +1 -1
  14. package/dist/components/ai/ai-chat-panel.js +15 -2
  15. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  16. package/dist/components/ai/translate-document-action.d.ts +6 -0
  17. package/dist/components/ai/translate-document-action.js.map +1 -1
  18. package/dist/components/ai/translate-document-dialog.d.ts +1 -1
  19. package/dist/components/ai/translate-document-dialog.js +5 -5
  20. package/dist/components/ai/translate-document-dialog.js.map +1 -1
  21. package/dist/components/assets/asset-detail.js +29 -11
  22. package/dist/components/assets/asset-detail.js.map +1 -1
  23. package/dist/components/assets/asset-locale.d.ts +16 -0
  24. package/dist/components/assets/asset-locale.js +30 -0
  25. package/dist/components/assets/asset-locale.js.map +1 -0
  26. package/dist/components/assets/asset-meta-drift-notice.d.ts +13 -0
  27. package/dist/components/assets/asset-meta-drift-notice.js +22 -0
  28. package/dist/components/assets/asset-meta-drift-notice.js.map +1 -0
  29. package/dist/components/assets/asset-meta-label.d.ts +13 -0
  30. package/dist/components/assets/asset-meta-label.js +17 -0
  31. package/dist/components/assets/asset-meta-label.js.map +1 -0
  32. package/dist/components/assets/asset-metadata.d.ts +89 -15
  33. package/dist/components/assets/asset-metadata.js +123 -11
  34. package/dist/components/assets/asset-metadata.js.map +1 -1
  35. package/dist/components/assets/asset-search.d.ts +6 -2
  36. package/dist/components/assets/asset-search.js +19 -3
  37. package/dist/components/assets/asset-search.js.map +1 -1
  38. package/dist/components/assets/asset-thumb.js +6 -2
  39. package/dist/components/assets/asset-thumb.js.map +1 -1
  40. package/dist/components/assets/assets-collection-view-source.d.ts +25 -9
  41. package/dist/components/assets/assets-collection-view-source.js +80 -26
  42. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  43. package/dist/components/assets/assets-store.js +17 -4
  44. package/dist/components/assets/assets-store.js.map +1 -1
  45. package/dist/components/builder/builder-store.d.ts +21 -0
  46. package/dist/components/builder/builder-store.js +22 -0
  47. package/dist/components/builder/builder-store.js.map +1 -1
  48. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  49. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  50. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  51. package/dist/components/builder/canvas-rebase.d.ts +101 -0
  52. package/dist/components/builder/canvas-rebase.js +179 -0
  53. package/dist/components/builder/canvas-rebase.js.map +1 -0
  54. package/dist/components/builder/canvas-render-context.d.ts +6 -3
  55. package/dist/components/builder/canvas-render-context.js +17 -8
  56. package/dist/components/builder/canvas-render-context.js.map +1 -1
  57. package/dist/components/builder/composition-field-editor.d.ts +26 -2
  58. package/dist/components/builder/composition-field-editor.js +46 -27
  59. package/dist/components/builder/composition-field-editor.js.map +1 -1
  60. package/dist/components/builder/node-factory.js +15 -3
  61. package/dist/components/builder/node-factory.js.map +1 -1
  62. package/dist/components/builder/property-inspector.js +3 -1
  63. package/dist/components/builder/property-inspector.js.map +1 -1
  64. package/dist/components/builder/run-inspector.js +10 -6
  65. package/dist/components/builder/run-inspector.js.map +1 -1
  66. package/dist/components/cms/asset-field.d.ts +5 -2
  67. package/dist/components/cms/asset-field.js +34 -17
  68. package/dist/components/cms/asset-field.js.map +1 -1
  69. package/dist/components/cms/asset-picker-core.d.ts +2 -2
  70. package/dist/components/cms/asset-picker-core.js +2 -2
  71. package/dist/components/cms/asset-picker-core.js.map +1 -1
  72. package/dist/components/cms/asset-picker.js +6 -2
  73. package/dist/components/cms/asset-picker.js.map +1 -1
  74. package/dist/components/cms/canvas-commit.d.ts +62 -0
  75. package/dist/components/cms/canvas-commit.js +39 -0
  76. package/dist/components/cms/canvas-commit.js.map +1 -0
  77. package/dist/components/cms/cms-store.d.ts +21 -8
  78. package/dist/components/cms/cms-store.js +19 -10
  79. package/dist/components/cms/cms-store.js.map +1 -1
  80. package/dist/components/cms/collection-table.js +27 -18
  81. package/dist/components/cms/collection-table.js.map +1 -1
  82. package/dist/components/cms/commit-policy.d.ts +6 -2
  83. package/dist/components/cms/commit-policy.js +1 -1
  84. package/dist/components/cms/commit-policy.js.map +1 -1
  85. package/dist/components/cms/detail-editor.js +12 -2
  86. package/dist/components/cms/detail-editor.js.map +1 -1
  87. package/dist/components/cms/draft-commit.d.ts +16 -3
  88. package/dist/components/cms/draft-commit.js +3 -2
  89. package/dist/components/cms/draft-commit.js.map +1 -1
  90. package/dist/components/cms/use-document-draft.d.ts +121 -5
  91. package/dist/components/cms/use-document-draft.js +197 -54
  92. package/dist/components/cms/use-document-draft.js.map +1 -1
  93. package/dist/components/cms/use-live-url.d.ts +6 -1
  94. package/dist/components/cms/use-live-url.js +12 -9
  95. package/dist/components/cms/use-live-url.js.map +1 -1
  96. package/dist/components/collection-view/cms-collection-view-source.js +11 -5
  97. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  98. package/dist/components/components/transfer-dialog.js +6 -4
  99. package/dist/components/components/transfer-dialog.js.map +1 -1
  100. package/dist/components/deployment-setup-gate.js +10 -1
  101. package/dist/components/deployment-setup-gate.js.map +1 -1
  102. package/dist/components/records/record-lens.js +3 -8
  103. package/dist/components/records/record-lens.js.map +1 -1
  104. package/dist/lib/ai/chat-state.d.ts +7 -0
  105. package/dist/lib/ai/chat-state.js.map +1 -1
  106. package/dist/lib/ai/tools.d.ts +8 -0
  107. package/dist/lib/ai/tools.js +4 -0
  108. package/dist/lib/ai/tools.js.map +1 -1
  109. package/dist/lib/assets/upload.d.ts +25 -7
  110. package/dist/lib/assets/upload.js +30 -21
  111. package/dist/lib/assets/upload.js.map +1 -1
  112. package/dist/lib/cms/published-documents.d.ts +24 -6
  113. package/dist/lib/cms/published-documents.js +39 -6
  114. package/dist/lib/cms/published-documents.js.map +1 -1
  115. package/dist/lib/cms-views/view-model.d.ts +25 -105
  116. package/dist/lib/cms-views/view-model.js +36 -103
  117. package/dist/lib/cms-views/view-model.js.map +1 -1
  118. package/dist/lib/collection-view/__fixtures__/document-writes.js +1 -0
  119. package/dist/lib/collection-view/__fixtures__/document-writes.js.map +1 -1
  120. package/dist/lib/collection-view/collection-view-source.d.ts +21 -8
  121. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  122. package/dist/lib/data/collection-content.js +5 -0
  123. package/dist/lib/data/collection-content.js.map +1 -1
  124. package/dist/lib/engine-version.d.ts +1 -1
  125. package/dist/lib/engine-version.js +1 -1
  126. package/dist/lib/engine-version.js.map +1 -1
  127. package/dist/lib/i18n/messages.de.js +20 -4
  128. package/dist/lib/i18n/messages.de.js.map +1 -1
  129. package/dist/lib/i18n/messages.en.d.ts +16 -1
  130. package/dist/lib/i18n/messages.en.js +22 -5
  131. package/dist/lib/i18n/messages.en.js.map +1 -1
  132. package/dist/lib/operations/cms-persistence.d.ts +80 -35
  133. package/dist/lib/operations/cms-persistence.js +348 -85
  134. package/dist/lib/operations/cms-persistence.js.map +1 -1
  135. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  136. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  137. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  138. package/dist/lib/persistence/convex-adapter.js +54 -8
  139. package/dist/lib/persistence/convex-adapter.js.map +1 -1
  140. package/dist/lib/persistence/deployment-readiness.js +14 -11
  141. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  142. package/dist/lib/persona/labels.js +2 -0
  143. package/dist/lib/persona/labels.js.map +1 -1
  144. package/dist/lib/query/index.d.ts +2 -2
  145. package/dist/lib/query/index.js +1 -1
  146. package/dist/lib/query/index.js.map +1 -1
  147. package/dist/lib/query/operations-query.d.ts +26 -0
  148. package/dist/lib/query/operations-query.js +12 -0
  149. package/dist/lib/query/operations-query.js.map +1 -1
  150. package/dist/lib/registry/index.js +2 -2
  151. package/dist/lib/registry/index.js.map +1 -1
  152. package/dist/lib/registry/preview.d.ts +10 -0
  153. package/dist/lib/registry/preview.js +13 -4
  154. package/dist/lib/registry/preview.js.map +1 -1
  155. package/dist/lib/routes/route-issues.js +6 -3
  156. package/dist/lib/routes/route-issues.js.map +1 -1
  157. package/dist/lib/workspace/workspace-source.js +6 -7
  158. package/dist/lib/workspace/workspace-source.js.map +1 -1
  159. package/dist/previews/entry-node.d.ts +14 -0
  160. package/dist/previews/entry-node.js +22 -0
  161. package/dist/previews/entry-node.js.map +1 -0
  162. package/dist/previews/harness.js +2 -16
  163. package/dist/previews/harness.js.map +1 -1
  164. package/package.json +3 -3
@@ -42,8 +42,16 @@ export interface UncommittedPosition {
42
42
  readonly base: number | undefined;
43
43
  }
44
44
  export interface DraftCommitter {
45
- /** A field's control wrote `path`. */
46
- edited(field: string, path: ValuePath): void;
45
+ /**
46
+ * A field's control wrote `path`. `authored` — the revision the edit was authored
47
+ * against when the writer knows it better than the draft does (EC-647: a canvas
48
+ * inspector control's burst is authored on the CANVAS's value, whose revision can trail
49
+ * the draft's); absent → {@link DraftCommitterOptions.baseOf}. A re-edit of an
50
+ * uncommitted position keeps the base of its first edit either way.
51
+ */
52
+ edited(field: string, path: ValuePath, authored?: {
53
+ readonly base: number | undefined;
54
+ }): void;
47
55
  /** Focus entered a field's control. */
48
56
  focused(field: string): void;
49
57
  /** Focus left a field's control (commits it and what rode along). */
@@ -61,7 +69,12 @@ export interface DraftCommitter {
61
69
  }
62
70
  export interface DraftCommitterOptions {
63
71
  policyOf: (field: string) => CommitPolicy;
64
- /** The revision the session's view is at now (captured as a position's base on its first edit). */
72
+ /**
73
+ * The revision the editor's draft is at now — the revision of the view a field's edit
74
+ * is authored on (captured as a position's base on its first edit). EC-647: the
75
+ * draft's, never the session's tracked one (which may hold a change the draft has not
76
+ * rebased onto yet).
77
+ */
65
78
  baseOf?: () => number | undefined;
66
79
  commit: (positions: readonly UncommittedPosition[]) => void;
67
80
  scheduler?: Scheduler;
@@ -29,10 +29,11 @@ export function createDraftCommitter(options) {
29
29
  options.commit(batch.map(([, position]) => position));
30
30
  };
31
31
  return {
32
- edited(field, path) {
32
+ edited(field, path, authored) {
33
33
  const key = formatValuePath(path);
34
34
  const previous = pending.get(key);
35
- pending.set(key, { field, path, base: previous ? previous.base : options.baseOf?.() });
35
+ const base = previous ? previous.base : authored ? authored.base : options.baseOf?.();
36
+ pending.set(key, { field, path, base });
36
37
  const policy = options.policyOf(field);
37
38
  if (policy === 'change' || focus === undefined) {
38
39
  commitField(field);
@@ -1 +1 @@
1
- {"version":3,"file":"draft-commit.js","sourceRoot":"","sources":["../../../src/components/cms/draft-commit.ts"],"names":[],"mappings":"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","sourcesContent":["import { formatValuePath } from '@elytracms/core/cms-core'\nimport type { ValuePath } from '@elytracms/core/cms-core'\nimport type { CommitPolicy } from './commit-policy'\nimport { IDLE_COMMIT_MS } from './commit-policy'\n\n/**\n * EC-595 A2 — the editor's COMMIT-ON-QUIESCENCE core (pure, clock injected; the house\n * pattern: no DOM, no React).\n *\n * The editor's draft changes per keystroke (live validation reads it); what is\n * COMMITTED — handed to the persister as patches — follows each field's\n * {@link CommitPolicy}. This core tracks the uncommitted positions and decides when\n * each is committed:\n *\n * - an edit of a `change`-policy field commits at once;\n * - an edit of the FOCUSED field waits for its blur, Enter (`blur-or-enter`) or idle\n * pause (`blur-or-idle`, re-armed on every edit — {@link Scheduler});\n * - an edit of a field that does NOT have focus (a derived value — the auto-slug\n * following the title, fill-from-base) rides along with the focused field's next\n * commit, or commits at once when no field has focus;\n * - {@link DraftCommitter.commitAll} is the FORCED commit (publish, unmount, tab hide,\n * duplicate, an AI proposal, transfer) — everything uncommitted, now.\n *\n * `commit` receives the positions; the caller builds the patches from its latest draft\n * (the value AT commit time, so a committed value is never a stale keystroke).\n */\n\n/** The injected clock: timers for the idle pause. */\nexport interface Scheduler {\n setTimeout(callback: () => void, ms: number): unknown\n clearTimeout(handle: unknown): void\n}\n\n/** The real clock (the editor's default). */\nexport const browserScheduler: Scheduler = {\n setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),\n clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),\n}\n\n/** One uncommitted position and the field (by name) whose control wrote it. */\nexport interface UncommittedPosition {\n readonly field: string\n readonly path: ValuePath\n /**\n * The revision the user's view was at when they STARTED editing this position — the\n * patch's base. A colleague's change of the same position that arrives (by\n * subscription) while the user is still typing is therefore refused on that field at\n * commit, never overwritten (EC-595 point 7: \"local wins until flushed, then the\n * per-path check decides\").\n */\n readonly base: number | undefined\n}\n\nexport interface DraftCommitter {\n /** A field's control wrote `path`. */\n edited(field: string, path: ValuePath): void\n /** Focus entered a field's control. */\n focused(field: string): void\n /** Focus left a field's control (commits it and what rode along). */\n blurred(field: string): void\n /** Enter in a single-line control (commits it). */\n entered(field: string): void\n /** The forced commit: everything uncommitted, now. */\n commitAll(): void\n /** The uncommitted positions (the editor overlays them on a rebased draft). */\n uncommitted(): readonly UncommittedPosition[]\n /** The field that has focus, if any. */\n focusedField(): string | undefined\n /** Cancel the idle timers (unmount). */\n dispose(): void\n}\n\nexport interface DraftCommitterOptions {\n policyOf: (field: string) => CommitPolicy\n /** The revision the session's view is at now (captured as a position's base on its first edit). */\n baseOf?: () => number | undefined\n commit: (positions: readonly UncommittedPosition[]) => void\n scheduler?: Scheduler\n idleMs?: number\n}\n\nexport function createDraftCommitter(options: DraftCommitterOptions): DraftCommitter {\n const scheduler = options.scheduler ?? browserScheduler\n const idleMs = options.idleMs ?? IDLE_COMMIT_MS\n /** Uncommitted positions by path key (one per position — a re-edit keeps one entry). */\n const pending = new Map<string, UncommittedPosition>()\n const idleTimers = new Map<string, unknown>()\n let focus: string | undefined\n\n const cancelIdle = (field: string) => {\n const handle = idleTimers.get(field)\n if (handle !== undefined) scheduler.clearTimeout(handle)\n idleTimers.delete(field)\n }\n\n /** Commit `field`'s positions plus every position of a field that does not have focus. */\n const commitField = (field: string) => {\n cancelIdle(field)\n const batch = [...pending.entries()].filter(\n ([, position]) => position.field === field || position.field !== focus,\n )\n if (batch.length === 0) return\n for (const [key] of batch) pending.delete(key)\n options.commit(batch.map(([, position]) => position))\n }\n\n return {\n edited(field, path) {\n const key = formatValuePath(path)\n const previous = pending.get(key)\n pending.set(key, { field, path, base: previous ? previous.base : options.baseOf?.() })\n const policy = options.policyOf(field)\n if (policy === 'change' || focus === undefined) {\n commitField(field)\n return\n }\n if (focus !== field) return // rides along with the focused field's commit\n if (policy === 'blur-or-idle') {\n cancelIdle(field)\n idleTimers.set(\n field,\n scheduler.setTimeout(() => {\n idleTimers.delete(field)\n commitField(field)\n }, idleMs),\n )\n }\n },\n focused(field) {\n if (focus !== undefined && focus !== field) commitField(focus)\n focus = field\n },\n blurred(field) {\n if (focus === field) focus = undefined\n commitField(field)\n },\n entered(field) {\n if (options.policyOf(field) === 'blur-or-enter') commitField(field)\n },\n commitAll() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n if (pending.size === 0) return\n const batch = [...pending.values()]\n pending.clear()\n options.commit(batch)\n },\n uncommitted: () => [...pending.values()],\n focusedField: () => focus,\n dispose() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n },\n }\n}\n"]}
1
+ {"version":3,"file":"draft-commit.js","sourceRoot":"","sources":["../../../src/components/cms/draft-commit.ts"],"names":[],"mappings":"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","sourcesContent":["import { formatValuePath } from '@elytracms/core/cms-core'\nimport type { ValuePath } from '@elytracms/core/cms-core'\nimport type { CommitPolicy } from './commit-policy'\nimport { IDLE_COMMIT_MS } from './commit-policy'\n\n/**\n * EC-595 A2 — the editor's COMMIT-ON-QUIESCENCE core (pure, clock injected; the house\n * pattern: no DOM, no React).\n *\n * The editor's draft changes per keystroke (live validation reads it); what is\n * COMMITTED — handed to the persister as patches — follows each field's\n * {@link CommitPolicy}. This core tracks the uncommitted positions and decides when\n * each is committed:\n *\n * - an edit of a `change`-policy field commits at once;\n * - an edit of the FOCUSED field waits for its blur, Enter (`blur-or-enter`) or idle\n * pause (`blur-or-idle`, re-armed on every edit — {@link Scheduler});\n * - an edit of a field that does NOT have focus (a derived value — the auto-slug\n * following the title, fill-from-base) rides along with the focused field's next\n * commit, or commits at once when no field has focus;\n * - {@link DraftCommitter.commitAll} is the FORCED commit (publish, unmount, tab hide,\n * duplicate, an AI proposal, transfer) — everything uncommitted, now.\n *\n * `commit` receives the positions; the caller builds the patches from its latest draft\n * (the value AT commit time, so a committed value is never a stale keystroke).\n */\n\n/** The injected clock: timers for the idle pause. */\nexport interface Scheduler {\n setTimeout(callback: () => void, ms: number): unknown\n clearTimeout(handle: unknown): void\n}\n\n/** The real clock (the editor's default). */\nexport const browserScheduler: Scheduler = {\n setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),\n clearTimeout: (handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>),\n}\n\n/** One uncommitted position and the field (by name) whose control wrote it. */\nexport interface UncommittedPosition {\n readonly field: string\n readonly path: ValuePath\n /**\n * The revision the user's view was at when they STARTED editing this position — the\n * patch's base. A colleague's change of the same position that arrives (by\n * subscription) while the user is still typing is therefore refused on that field at\n * commit, never overwritten (EC-595 point 7: \"local wins until flushed, then the\n * per-path check decides\").\n */\n readonly base: number | undefined\n}\n\nexport interface DraftCommitter {\n /**\n * A field's control wrote `path`. `authored` — the revision the edit was authored\n * against when the writer knows it better than the draft does (EC-647: a canvas\n * inspector control's burst is authored on the CANVAS's value, whose revision can trail\n * the draft's); absent → {@link DraftCommitterOptions.baseOf}. A re-edit of an\n * uncommitted position keeps the base of its first edit either way.\n */\n edited(field: string, path: ValuePath, authored?: { readonly base: number | undefined }): void\n /** Focus entered a field's control. */\n focused(field: string): void\n /** Focus left a field's control (commits it and what rode along). */\n blurred(field: string): void\n /** Enter in a single-line control (commits it). */\n entered(field: string): void\n /** The forced commit: everything uncommitted, now. */\n commitAll(): void\n /** The uncommitted positions (the editor overlays them on a rebased draft). */\n uncommitted(): readonly UncommittedPosition[]\n /** The field that has focus, if any. */\n focusedField(): string | undefined\n /** Cancel the idle timers (unmount). */\n dispose(): void\n}\n\nexport interface DraftCommitterOptions {\n policyOf: (field: string) => CommitPolicy\n /**\n * The revision the editor's draft is at now — the revision of the view a field's edit\n * is authored on (captured as a position's base on its first edit). EC-647: the\n * draft's, never the session's tracked one (which may hold a change the draft has not\n * rebased onto yet).\n */\n baseOf?: () => number | undefined\n commit: (positions: readonly UncommittedPosition[]) => void\n scheduler?: Scheduler\n idleMs?: number\n}\n\nexport function createDraftCommitter(options: DraftCommitterOptions): DraftCommitter {\n const scheduler = options.scheduler ?? browserScheduler\n const idleMs = options.idleMs ?? IDLE_COMMIT_MS\n /** Uncommitted positions by path key (one per position — a re-edit keeps one entry). */\n const pending = new Map<string, UncommittedPosition>()\n const idleTimers = new Map<string, unknown>()\n let focus: string | undefined\n\n const cancelIdle = (field: string) => {\n const handle = idleTimers.get(field)\n if (handle !== undefined) scheduler.clearTimeout(handle)\n idleTimers.delete(field)\n }\n\n /** Commit `field`'s positions plus every position of a field that does not have focus. */\n const commitField = (field: string) => {\n cancelIdle(field)\n const batch = [...pending.entries()].filter(\n ([, position]) => position.field === field || position.field !== focus,\n )\n if (batch.length === 0) return\n for (const [key] of batch) pending.delete(key)\n options.commit(batch.map(([, position]) => position))\n }\n\n return {\n edited(field, path, authored) {\n const key = formatValuePath(path)\n const previous = pending.get(key)\n const base = previous ? previous.base : authored ? authored.base : options.baseOf?.()\n pending.set(key, { field, path, base })\n const policy = options.policyOf(field)\n if (policy === 'change' || focus === undefined) {\n commitField(field)\n return\n }\n if (focus !== field) return // rides along with the focused field's commit\n if (policy === 'blur-or-idle') {\n cancelIdle(field)\n idleTimers.set(\n field,\n scheduler.setTimeout(() => {\n idleTimers.delete(field)\n commitField(field)\n }, idleMs),\n )\n }\n },\n focused(field) {\n if (focus !== undefined && focus !== field) commitField(focus)\n focus = field\n },\n blurred(field) {\n if (focus === field) focus = undefined\n commitField(field)\n },\n entered(field) {\n if (options.policyOf(field) === 'blur-or-enter') commitField(field)\n },\n commitAll() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n if (pending.size === 0) return\n const batch = [...pending.values()]\n pending.clear()\n options.commit(batch)\n },\n uncommitted: () => [...pending.values()],\n focusedField: () => focus,\n dispose() {\n for (const field of [...idleTimers.keys()]) cancelIdle(field)\n },\n }\n}\n"]}
@@ -1,7 +1,10 @@
1
1
  import type { PresenceEntry } from '@elytracms/core/persistence';
2
2
  import type { CmsDocument, CollectionDef, DocumentPatch, FieldDef, Locale, PropFieldLookup, ValuePath } from '@elytracms/core/cms-core';
3
3
  import type { CollectionViewSource } from '../../lib/collection-view/collection-view-source.js';
4
- import type { Scheduler, UncommittedPosition } from './draft-commit.js';
4
+ import type { CanvasFieldChange, CanvasLift } from '../builder/canvas-rebase.js';
5
+ import type { CommitPolicy } from './commit-policy.js';
6
+ import type { CanvasControl, CanvasControlPosition } from './canvas-commit.js';
7
+ import type { DraftCommitter, Scheduler, UncommittedPosition } from './draft-commit.js';
5
8
  /**
6
9
  * EC-595 A2 — the detail editor's draft over the revisioned document (and, for the
7
10
  * explicit-save rails, over a record).
@@ -10,7 +13,9 @@ import type { Scheduler, UncommittedPosition } from './draft-commit.js';
10
13
  * reads it); a field's edit is COMMITTED per its control kind's policy
11
14
  * ({@link commitPolicyOf} / {@link createDraftCommitter}) as the patch of the position it
12
15
  * wrote — never a document. The canvas commits each action's node patches at once
13
- * (`compositionPatches`). The draft REBASES whenever the session's document changes
16
+ * (`compositionPatches`); a burst of typing in one of its inspector controls is that
17
+ * control's uncommitted position, committed on its kind's policy like a form field
18
+ * (EC-630, `canvas-commit.ts`). The draft REBASES whenever the session's document changes
14
19
  * (a write result, a colleague's change by subscription, a resolved conflict): it is the
15
20
  * session's document with this editor's uncommitted positions laid over it — local wins
16
21
  * until committed, then the per-path check decides (EC-595 point 7).
@@ -35,6 +40,26 @@ export interface VisibilitySource {
35
40
  onHidden(onHidden: () => void): () => void;
36
41
  }
37
42
  export declare const documentVisibility: VisibilitySource;
43
+ /**
44
+ * EC-647 — the editor's draft and the revision of the session view it was last rebased
45
+ * onto, as ONE value: every edit made on the draft was authored against that revision (an
46
+ * uncommitted position keeps the revision its first keystroke saw). `undefined`: the view
47
+ * had no revision (a fixture session, a record rail, a document still being created).
48
+ */
49
+ export interface DraftView {
50
+ readonly document: CmsDocument;
51
+ readonly revision: number | undefined;
52
+ }
53
+ /**
54
+ * EC-647 — the slot alias write (pure): the draft with `alias` set (or removed), written
55
+ * whole AT THE DRAFT'S REVISION — never the session's tracked one, which may already hold
56
+ * a colleague's change the draft has not absorbed (Codex F10). There is no identity-
57
+ * metadata operation for the alias (the value-position grammar has no position for it,
58
+ * measured on the base), so it stays a whole-document `replace` with its precondition.
59
+ */
60
+ export declare function aliasWrite(draft: DraftView, alias: string | undefined, writes: {
61
+ replace(doc: CmsDocument, revision: number | undefined): void;
62
+ }): CmsDocument;
38
63
  /** The patch that writes the value `doc` holds at `path` (unset when absent). */
39
64
  export declare function positionPatch(doc: CmsDocument, path: ValuePath): DocumentPatch;
40
65
  /**
@@ -42,6 +67,65 @@ export declare function positionPatch(doc: CmsDocument, path: ValuePath): Docume
42
67
  * over it, values from the editor's current draft.
43
68
  */
44
69
  export declare function rebaseDraft(sessionDoc: CmsDocument, draft: CmsDocument, uncommitted: readonly UncommittedPosition[]): CmsDocument;
70
+ /**
71
+ * EC-630 — the canvas's write into the draft (pure). The canvas writer's node patches,
72
+ * keyed by node id, are the canvas's OWN delta: from `base`, the value it last agreed on
73
+ * with the draft, to `value` — never a document diff, and never measured against the
74
+ * draft's CURRENT value (a colleague's change that reached the draft a moment before the
75
+ * edit lifted would read as part of the edit, and its patches would revert it). The draft
76
+ * takes the delta over whatever it holds now (normally `base` itself) and canonicalises the
77
+ * field through the write lens. `undefined` when the canvas changed nothing.
78
+ */
79
+ export declare function canvasWrite(input: {
80
+ collection: CollectionDef;
81
+ draft: CmsDocument;
82
+ field: string;
83
+ locale: Locale;
84
+ value: unknown;
85
+ base: unknown;
86
+ lookupManifest: PropFieldLookup;
87
+ }): {
88
+ next: CmsDocument;
89
+ patches: DocumentPatch[];
90
+ } | undefined;
91
+ /**
92
+ * EC-630 — the change of the canvas field made UNDERNEATH the canvas when the draft
93
+ * rebased from `previous` to `next` (pure), numbered after `last`; `undefined` when the
94
+ * field did not move. The canvas's own edits are in both values (the session's view holds
95
+ * every patch it committed, the draft overlays every position it has not), so the change
96
+ * is someone else's: a colleague's patch, a write's canonical row, a resolved conflict.
97
+ *
98
+ * EC-647: it carries `revision`, the revision the draft is at after the rebase, and is
99
+ * reported when only THAT moved too (`from` ≡ `to`): the canvas's agreed value is valid
100
+ * at the newer revision, so its next edit is authored there — the revision follows the
101
+ * value, never runs ahead of it.
102
+ */
103
+ export declare function canvasFieldChange(previous: CmsDocument, next: CmsDocument, path: ValuePath, last: CanvasFieldChange | undefined, revision: number | undefined): CanvasFieldChange | undefined;
104
+ /**
105
+ * EC-647 — the canvas's write path (pure; what {@link DocumentDraft.setCanvasValue} runs):
106
+ * the canvas writer's delta into the draft ({@link canvasWrite}, handed to `apply`), then
107
+ * routed ({@link routeCanvasWrite}) — committed at once, or held as a focused inspector
108
+ * control's uncommitted position — and in both cases paired with the revision of the
109
+ * canvas value the edit was authored on (`lift.revision`), never the draft's: the draft
110
+ * may already hold a colleague's change the canvas has not been rebased onto (Codex F12),
111
+ * and the store must check the edit from the canvas's revision to refuse it where it
112
+ * overlaps that change. `commit` absent → a draft without a revisioned write model (the
113
+ * draft moves, nothing is committed). `undefined` when the canvas changed nothing.
114
+ */
115
+ export declare function canvasCommit(input: {
116
+ collection: CollectionDef;
117
+ draft: CmsDocument;
118
+ field: string;
119
+ locale: Locale;
120
+ lift: CanvasLift;
121
+ lookupManifest: PropFieldLookup;
122
+ /** The focused canvas inspector control, if focus is on one. */
123
+ focused: CanvasControl | undefined;
124
+ committer: DraftCommitter;
125
+ /** The draft takes the canvas's write (before it is routed — a forced commit reads it). */
126
+ apply: (next: CmsDocument) => void;
127
+ commit?: (patches: readonly DocumentPatch[], bases: readonly (number | undefined)[]) => void;
128
+ }): 'deferred' | 'committed' | 'draft' | undefined;
45
129
  /**
46
130
  * The reader's "now" for presence liveness: THIS session's own latest heartbeat `at`
47
131
  * (written by the same store clock as everyone else's — the playground store runs a
@@ -53,6 +137,12 @@ export declare function rebaseDraft(sessionDoc: CmsDocument, draft: CmsDocument,
53
137
  export declare function presenceReaderNow(entries: readonly PresenceEntry[], sessionId: string, fallback: string): string;
54
138
  export interface DocumentDraft {
55
139
  draft: CmsDocument;
140
+ /**
141
+ * EC-647 — the revision of the session view the draft was last rebased onto (see
142
+ * {@link DraftView}): a writer that authors from {@link draft} (the translation) writes
143
+ * at this revision.
144
+ */
145
+ revision: number | undefined;
56
146
  /** Uncommitted (document model) / unsaved (record model) edits exist. */
57
147
  dirty: boolean;
58
148
  /** The record model: the editor renders a Save button + navigation guard. */
@@ -61,22 +151,48 @@ export interface DocumentDraft {
61
151
  setFieldValue(field: FieldDef, value: unknown): void;
62
152
  /** A writer that transforms ONE field position (fill / reset from base). */
63
153
  editField(field: FieldDef, update: (draft: CmsDocument) => CmsDocument): void;
64
- /** The canvas emitted a new value for its field (node patches, committed at once). */
65
- setCanvasValue(field: FieldDef, value: unknown): void;
154
+ /**
155
+ * The canvas emitted a new value for its field (node patches, committed at once). The
156
+ * lift's `base` is the value the canvas last agreed on with the draft: the canvas
157
+ * writer's delta is `base` → `value`, applied over whatever the draft holds now (EC-630 —
158
+ * a change that landed underneath meanwhile is never reverted by the canvas's own
159
+ * write); its `revision` is the revision that value was agreed at — the revision the
160
+ * edit is committed at (EC-647, {@link canvasCommit}).
161
+ */
162
+ setCanvasValue(field: FieldDef, lift: CanvasLift): void;
66
163
  /** The slot alias (a document-level key: a whole-document write at the held revision). */
67
164
  setAlias(alias: string | undefined): void;
68
165
  /** Field boundary events (focus / blur / Enter) — the commit policy's triggers. */
69
166
  focused(field: FieldDef): void;
70
167
  blurred(field: FieldDef): void;
71
168
  entered(field: FieldDef): void;
169
+ /**
170
+ * EC-630 — the canvas inspector's boundary events: a control writing `position` of the
171
+ * canvas field `field`, committing on `policy` (THE declaration for its kind). A burst of
172
+ * typing in it is its uncommitted position until blur / Enter / the idle pause.
173
+ */
174
+ canvasFocused(field: FieldDef, position: CanvasControlPosition, policy: CommitPolicy): void;
175
+ canvasBlurred(field: FieldDef, position: CanvasControlPosition): void;
176
+ canvasEntered(field: FieldDef, position: CanvasControlPosition): void;
72
177
  /** The forced commit (sync; the persister serialises what follows) / the record model's Save. */
73
178
  save(): void;
74
179
  /** The FORCED FLUSH: commit everything uncommitted and await persistence. */
75
180
  flush(): Promise<void>;
76
181
  /** Adopt a draft from outside (a restored version). */
77
182
  replaceDraft(next: CmsDocument): void;
78
- /** Bumped when the canvas must remount from the draft (a restore, a colleague's change). */
183
+ /**
184
+ * Bumped when the canvas must remount from the draft: a restored version replaced the
185
+ * document, or a locale switch pointed the canvas at another bucket. (A change made
186
+ * underneath the canvas is NOT a remount — {@link canvasChange}.)
187
+ */
79
188
  canvasNonce: number;
189
+ /**
190
+ * EC-630 — the latest change of the canvas field's value made UNDERNEATH the canvas (a
191
+ * colleague's patch by subscription, a write's canonical row, a resolved conflict): the
192
+ * draft's value before and after it. The canvas REBASES onto it — the change lands, its
193
+ * undo history and selection survive — where it used to remount and lose both.
194
+ */
195
+ canvasChange: CanvasFieldChange | undefined;
80
196
  /** The OTHER sessions present on this document, live by the reader's clock. */
81
197
  presence: readonly PresenceEntry[];
82
198
  }
@@ -4,6 +4,7 @@ import { applyDocumentPatches, formatValuePath, readValueAt, semanticValueEqual,
4
4
  import { fieldPositionPath } from '../../lib/cms/field-patch.js';
5
5
  import { compositionPatches } from '../builder/composition-patches.js';
6
6
  import { commitPolicyOf } from './commit-policy.js';
7
+ import { canvasControl, routeCanvasWrite } from './canvas-commit.js';
7
8
  import { browserScheduler, createDraftCommitter } from './draft-commit.js';
8
9
  /**
9
10
  * EC-595 A2 — the detail editor's draft over the revisioned document (and, for the
@@ -13,7 +14,9 @@ import { browserScheduler, createDraftCommitter } from './draft-commit.js';
13
14
  * reads it); a field's edit is COMMITTED per its control kind's policy
14
15
  * ({@link commitPolicyOf} / {@link createDraftCommitter}) as the patch of the position it
15
16
  * wrote — never a document. The canvas commits each action's node patches at once
16
- * (`compositionPatches`). The draft REBASES whenever the session's document changes
17
+ * (`compositionPatches`); a burst of typing in one of its inspector controls is that
18
+ * control's uncommitted position, committed on its kind's policy like a form field
19
+ * (EC-630, `canvas-commit.ts`). The draft REBASES whenever the session's document changes
17
20
  * (a write result, a colleague's change by subscription, a resolved conflict): it is the
18
21
  * session's document with this editor's uncommitted positions laid over it — local wins
19
22
  * until committed, then the per-path check decides (EC-595 point 7).
@@ -44,6 +47,22 @@ export const documentVisibility = {
44
47
  return () => document.removeEventListener('visibilitychange', listener);
45
48
  },
46
49
  };
50
+ /**
51
+ * EC-647 — the slot alias write (pure): the draft with `alias` set (or removed), written
52
+ * whole AT THE DRAFT'S REVISION — never the session's tracked one, which may already hold
53
+ * a colleague's change the draft has not absorbed (Codex F10). There is no identity-
54
+ * metadata operation for the alias (the value-position grammar has no position for it,
55
+ * measured on the base), so it stays a whole-document `replace` with its precondition.
56
+ */
57
+ export function aliasWrite(draft, alias, writes) {
58
+ const next = { ...draft.document };
59
+ if (alias === undefined)
60
+ delete next.alias;
61
+ else
62
+ next.alias = alias;
63
+ writes.replace(next, draft.revision);
64
+ return next;
65
+ }
47
66
  /** The patch that writes the value `doc` holds at `path` (unset when absent). */
48
67
  export function positionPatch(doc, path) {
49
68
  const value = readValueAt(doc, path);
@@ -59,6 +78,81 @@ export function rebaseDraft(sessionDoc, draft, uncommitted) {
59
78
  const patches = uncommitted.map((position) => positionPatch(draft, position.path));
60
79
  return applyDocumentPatches({ document: sessionDoc, patches, since: [] }).document;
61
80
  }
81
+ /**
82
+ * EC-630 — the canvas's write into the draft (pure). The canvas writer's node patches,
83
+ * keyed by node id, are the canvas's OWN delta: from `base`, the value it last agreed on
84
+ * with the draft, to `value` — never a document diff, and never measured against the
85
+ * draft's CURRENT value (a colleague's change that reached the draft a moment before the
86
+ * edit lifted would read as part of the edit, and its patches would revert it). The draft
87
+ * takes the delta over whatever it holds now (normally `base` itself) and canonicalises the
88
+ * field through the write lens. `undefined` when the canvas changed nothing.
89
+ */
90
+ export function canvasWrite(input) {
91
+ const { collection, draft, field, locale, lookupManifest } = input;
92
+ const path = fieldPositionPath(collection, field, locale);
93
+ const localized = path.length > 1 ? locale : undefined;
94
+ const patches = compositionPatches(input.base, input.value, field, localized);
95
+ if (patches.length === 0)
96
+ return undefined;
97
+ const applied = applyDocumentPatches({ document: draft, patches, since: [] }).document;
98
+ return { next: writeField(collection, draft, field, locale, readValueAt(applied, path), { lookupManifest }), patches };
99
+ }
100
+ /**
101
+ * EC-630 — the change of the canvas field made UNDERNEATH the canvas when the draft
102
+ * rebased from `previous` to `next` (pure), numbered after `last`; `undefined` when the
103
+ * field did not move. The canvas's own edits are in both values (the session's view holds
104
+ * every patch it committed, the draft overlays every position it has not), so the change
105
+ * is someone else's: a colleague's patch, a write's canonical row, a resolved conflict.
106
+ *
107
+ * EC-647: it carries `revision`, the revision the draft is at after the rebase, and is
108
+ * reported when only THAT moved too (`from` ≡ `to`): the canvas's agreed value is valid
109
+ * at the newer revision, so its next edit is authored there — the revision follows the
110
+ * value, never runs ahead of it.
111
+ */
112
+ export function canvasFieldChange(previous, next, path, last, revision) {
113
+ const from = readValueAt(previous, path);
114
+ const to = readValueAt(next, path);
115
+ if (semanticValueEqual(from, to) && (last === undefined || last.revision === revision))
116
+ return undefined;
117
+ return { from, to, seq: (last?.seq ?? 0) + 1, revision };
118
+ }
119
+ /**
120
+ * EC-647 — the canvas's write path (pure; what {@link DocumentDraft.setCanvasValue} runs):
121
+ * the canvas writer's delta into the draft ({@link canvasWrite}, handed to `apply`), then
122
+ * routed ({@link routeCanvasWrite}) — committed at once, or held as a focused inspector
123
+ * control's uncommitted position — and in both cases paired with the revision of the
124
+ * canvas value the edit was authored on (`lift.revision`), never the draft's: the draft
125
+ * may already hold a colleague's change the canvas has not been rebased onto (Codex F12),
126
+ * and the store must check the edit from the canvas's revision to refuse it where it
127
+ * overlaps that change. `commit` absent → a draft without a revisioned write model (the
128
+ * draft moves, nothing is committed). `undefined` when the canvas changed nothing.
129
+ */
130
+ export function canvasCommit(input) {
131
+ const { lift, commit } = input;
132
+ const write = canvasWrite({
133
+ collection: input.collection,
134
+ draft: input.draft,
135
+ field: input.field,
136
+ locale: input.locale,
137
+ value: lift.value,
138
+ base: lift.base,
139
+ lookupManifest: input.lookupManifest,
140
+ });
141
+ if (!write)
142
+ return undefined;
143
+ input.apply(write.next);
144
+ if (!commit)
145
+ return 'draft';
146
+ // EC-630: a burst of typing in a focused inspector control is that control's
147
+ // uncommitted position (its policy commits it); a canvas action commits now.
148
+ return routeCanvasWrite({
149
+ patches: write.patches,
150
+ focused: input.focused,
151
+ committer: input.committer,
152
+ revision: lift.revision,
153
+ commitNow: (patches) => commit(patches, patches.map(() => lift.revision)),
154
+ });
155
+ }
62
156
  /**
63
157
  * The reader's "now" for presence liveness: THIS session's own latest heartbeat `at`
64
158
  * (written by the same store clock as everyone else's — the playground store runs a
@@ -84,63 +178,65 @@ export function useDocumentDraft(input) {
84
178
  const writes = source.writes;
85
179
  const documentModel = writes.kind === 'document' ? writes : undefined;
86
180
  const ref = useMemo(() => ({ collection: doc.collection, id: doc.id }), [doc.collection, doc.id]);
87
- const [draft, setDraftState] = useState(doc);
88
- const draftRef = useRef(draft);
181
+ // EC-647: the draft and the revision of the view it rebased onto travel as ONE value.
182
+ const [view, setViewState] = useState(() => ({
183
+ document: doc,
184
+ revision: documentModel?.revisionOfView(doc),
185
+ }));
186
+ const draft = view.document;
187
+ const draftRef = useRef(view);
89
188
  // EC-616: the record as the editor opened it (or as of their last successful save) — the
90
189
  // BASE a record rail diffs a save against, so it writes only the editor's changes. It moves
91
190
  // only while the draft equals the source's value (at open, and once a save is reflected
92
191
  // back): another writer's change arriving mid-edit never becomes the editor's base.
93
192
  const baseRef = useRef(doc);
94
- baseRef.current = recordSaveBase(baseRef.current, draftRef.current, doc);
95
- const setDraft = useCallback((next) => {
193
+ baseRef.current = recordSaveBase(baseRef.current, draftRef.current.document, doc);
194
+ const setView = useCallback((next) => {
96
195
  draftRef.current = next;
97
- setDraftState(next);
196
+ setViewState(next);
98
197
  }, []);
198
+ /** An edit of the draft: its value moves, the revision it is authored against does not. */
199
+ const setDraft = useCallback((next) => setView({ document: next, revision: draftRef.current.revision }), [setView]);
99
200
  const scheduler = input.scheduler ?? browserScheduler;
100
201
  const now = input.now ?? (() => new Date().toISOString());
101
202
  const fieldsByName = useMemo(() => new Map(collection.fields.map((f) => [f.name, f])), [collection]);
102
203
  // --- Committing --------------------------------------------------------------------
103
204
  const writesRef = useRef(documentModel);
104
205
  writesRef.current = documentModel;
206
+ /** EC-630 — the canvas inspector controls that took focus, by committer key (their position + policy). */
207
+ const canvasControls = useRef(new Map());
105
208
  const committer = useMemo(() => createDraftCommitter({
106
209
  policyOf: (name) => {
107
210
  const field = fieldsByName.get(name);
108
- return field ? commitPolicyOf(field) : 'blur-or-idle';
211
+ if (field)
212
+ return commitPolicyOf(field);
213
+ return canvasControls.current.get(name)?.policy ?? 'blur-or-idle';
109
214
  },
110
215
  commit: (positions) => {
111
216
  const target = writesRef.current;
112
217
  if (!target)
113
218
  return;
114
- void target.commit(ref, positions.map((position) => positionPatch(draftRef.current, position.path)), positions.map((position) => position.base));
219
+ void target.commit(ref, positions.map((position) => positionPatch(draftRef.current.document, position.path)), positions.map((position) => position.base));
115
220
  },
116
- baseOf: () => writesRef.current?.revisionOf(ref),
221
+ // EC-647: a position's base is the revision of the DRAFT its first keystroke was
222
+ // made on — never the session's tracked revision, which may already hold a
223
+ // colleague's change the draft has not rebased onto yet.
224
+ baseOf: () => draftRef.current.revision,
117
225
  scheduler,
118
226
  }),
119
227
  // One committer per open document (the editor is keyed by it).
120
228
  [ref]);
121
- // --- The canvas's value and its remount ---------------------------------------------
229
+ // --- The canvas's value: its remount and its rebase ------------------------------------
122
230
  const canvasPath = canvasField ? fieldPositionPath(collection, canvasField.name, locale) : undefined;
123
231
  const canvasPathKey = canvasPath ? formatValuePath(canvasPath) : '';
124
- const canvasValueRef = useRef(canvasPath ? readValueAt(draft, canvasPath) : undefined);
125
232
  const [canvasNonce, setCanvasNonce] = useState(0);
126
- const syncCanvas = useCallback((next) => {
127
- if (!canvasPath)
128
- return;
129
- const value = readValueAt(next, canvasPath);
130
- if (semanticValueEqual(value, canvasValueRef.current))
131
- return;
132
- canvasValueRef.current = value;
133
- setCanvasNonce((n) => n + 1);
134
- },
135
- // The path is keyed by its string form.
136
- [canvasPathKey]);
233
+ const [canvasChange, setCanvasChange] = useState(undefined);
137
234
  // A locale switch points the canvas at another bucket: remount from it.
138
235
  const lastCanvasPathKey = useRef(canvasPathKey);
139
236
  useEffect(() => {
140
237
  if (lastCanvasPathKey.current === canvasPathKey)
141
238
  return;
142
239
  lastCanvasPathKey.current = canvasPathKey;
143
- canvasValueRef.current = canvasPath ? readValueAt(draftRef.current, canvasPath) : undefined;
144
240
  setCanvasNonce((n) => n + 1);
145
241
  }, [canvasPathKey]);
146
242
  // --- The rebase (document model) -------------------------------------------------------
@@ -152,54 +248,64 @@ export function useDocumentDraft(input) {
152
248
  }
153
249
  if (!documentModel)
154
250
  return;
155
- const next = rebaseDraft(doc, draftRef.current, committer.uncommitted());
156
- setDraft(next);
157
- syncCanvas(next);
251
+ const previous = draftRef.current.document;
252
+ const next = rebaseDraft(doc, previous, committer.uncommitted());
253
+ // EC-647: the rebase moves the value AND the revision — the session view's own.
254
+ const revision = documentModel.revisionOfView(doc);
255
+ setView({ document: next, revision });
256
+ // EC-630: the canvas field moved underneath the canvas — the canvas rebases onto the
257
+ // change (its own edits are in both values: the session's view holds every patch it
258
+ // committed, and the draft overlays every position it has not). EC-647: the change
259
+ // carries the revision, so the canvas's next edit is authored where its value is.
260
+ if (!canvasPath)
261
+ return;
262
+ setCanvasChange((last) => canvasFieldChange(previous, next, canvasPath, last, revision) ?? last);
158
263
  }, [doc]);
159
264
  // --- Writers ----------------------------------------------------------------------------
160
265
  const setFieldValue = useCallback((field, value) => {
161
- const next = writeField(collection, draftRef.current, field.name, locale, value, { lookupManifest });
266
+ const next = writeField(collection, draftRef.current.document, field.name, locale, value, { lookupManifest });
162
267
  setDraft(next);
163
268
  if (documentModel)
164
269
  committer.edited(field.name, fieldPositionPath(collection, field.name, locale));
165
270
  }, [collection, locale, lookupManifest, setDraft, documentModel, committer]);
166
271
  const editField = useCallback((field, update) => {
167
- setDraft(update(draftRef.current));
272
+ setDraft(update(draftRef.current.document));
168
273
  if (documentModel)
169
274
  committer.edited(field.name, fieldPositionPath(collection, field.name, locale));
170
275
  }, [collection, locale, setDraft, documentModel, committer]);
171
- const setCanvasValue = useCallback((field, value) => {
172
- const path = fieldPositionPath(collection, field.name, locale);
173
- const before = readValueAt(draftRef.current, path);
174
- const next = writeField(collection, draftRef.current, field.name, locale, value, { lookupManifest });
175
- const after = readValueAt(next, path);
176
- setDraft(next);
177
- canvasValueRef.current = after;
178
- if (!documentModel)
179
- return;
180
- // The canvas writer's node patches, keyed by node id (computed here, in its write
181
- // path, from the canonical values before and after the action — never a document diff).
182
- const localized = path.length > 1 ? locale : undefined;
183
- void documentModel.commit(ref, compositionPatches(before, after, field.name, localized));
184
- }, [collection, locale, lookupManifest, setDraft, documentModel, ref]);
276
+ const setCanvasValue = useCallback((field, lift) => {
277
+ const focus = committer.focusedField();
278
+ canvasCommit({
279
+ collection,
280
+ draft: draftRef.current.document,
281
+ field: field.name,
282
+ locale,
283
+ lift,
284
+ lookupManifest,
285
+ focused: focus === undefined ? undefined : canvasControls.current.get(focus),
286
+ committer,
287
+ apply: setDraft,
288
+ ...(documentModel
289
+ ? { commit: (patches, bases) => void documentModel.commit(ref, patches, bases) }
290
+ : {}),
291
+ });
292
+ }, [collection, locale, lookupManifest, setDraft, documentModel, ref, committer]);
185
293
  const setAlias = useCallback((alias) => {
186
- const next = { ...draftRef.current };
187
- if (alias === undefined)
188
- delete next.alias;
189
- else
190
- next.alias = alias;
191
- setDraft(next);
192
- if (!documentModel)
294
+ if (!documentModel) {
295
+ aliasWrite(draftRef.current, alias, { replace: (next) => setDraft(next) });
193
296
  return;
297
+ }
298
+ // The uncommitted positions first (the snapshot holds them), then the snapshot AT
299
+ // THE DRAFT'S REVISION (EC-647 — Codex F10).
194
300
  committer.commitAll();
195
- documentModel.replace(next);
301
+ setDraft(aliasWrite(draftRef.current, alias, documentModel));
196
302
  }, [setDraft, documentModel, committer]);
197
303
  // --- Forced flush ----------------------------------------------------------------------
198
304
  const save = useCallback(() => {
199
305
  if (documentModel)
200
306
  committer.commitAll();
201
307
  else if (writes.kind === 'record')
202
- writes.save(draftRef.current, baseRef.current);
308
+ writes.save(draftRef.current.document, baseRef.current);
203
309
  }, [documentModel, committer, writes]);
204
310
  const flush = useCallback(async () => {
205
311
  save();
@@ -271,13 +377,46 @@ export function useDocumentDraft(input) {
271
377
  if (documentModel)
272
378
  committer.entered(field.name);
273
379
  }, [documentModel, committer]);
380
+ // EC-630 — the canvas inspector's commit boundary: a control at a position of the canvas
381
+ // field gained focus, lost it, or saw Enter. Its policy is THE declaration's for its kind.
382
+ const canvasControlOf = useCallback((field, position, policy) => canvasControl(fieldPositionPath(collection, field.name, locale), position, policy), [collection, locale]);
383
+ const canvasFocused = useCallback((field, position, policy) => {
384
+ if (!documentModel)
385
+ return;
386
+ const control = canvasControlOf(field, position, policy);
387
+ canvasControls.current.set(control.key, control);
388
+ committer.focused(control.key);
389
+ focusedPathRef.current = control.path;
390
+ heartbeat();
391
+ }, [documentModel, committer, canvasControlOf, heartbeat]);
392
+ const canvasBlurred = useCallback((field, position) => {
393
+ if (!documentModel)
394
+ return;
395
+ const key = canvasControlOf(field, position, 'change').key;
396
+ committer.blurred(key);
397
+ canvasControls.current.delete(key);
398
+ focusedPathRef.current = undefined;
399
+ heartbeat();
400
+ }, [documentModel, committer, canvasControlOf, heartbeat]);
401
+ const canvasEntered = useCallback((field, position) => {
402
+ if (documentModel)
403
+ committer.entered(canvasControlOf(field, position, 'change').key);
404
+ }, [documentModel, committer, canvasControlOf]);
274
405
  const replaceDraft = useCallback((next) => {
275
- setDraft(next);
276
- syncCanvas(next);
277
- }, [setDraft, syncCanvas]);
406
+ // A restored version replaces the document: when it moved the canvas field, the
407
+ // canvas remounts from it (its history belonged to the replaced value).
408
+ const moved = canvasPath && !semanticValueEqual(readValueAt(draftRef.current.document, canvasPath), readValueAt(next, canvasPath));
409
+ // EC-647: the restored value comes with the revision the session showed it at.
410
+ setView({ document: next, revision: documentModel?.revisionOfView(next) });
411
+ if (moved)
412
+ setCanvasNonce((n) => n + 1);
413
+ },
414
+ // The path is keyed by its string form.
415
+ [setView, documentModel, canvasPathKey]);
278
416
  const dirty = useMemo(() => !semanticValueEqual(draft, doc), [draft, doc]);
279
417
  return {
280
418
  draft,
419
+ revision: view.revision,
281
420
  dirty,
282
421
  explicitSave: writes.kind === 'record',
283
422
  setFieldValue,
@@ -287,10 +426,14 @@ export function useDocumentDraft(input) {
287
426
  focused,
288
427
  blurred,
289
428
  entered,
429
+ canvasFocused,
430
+ canvasBlurred,
431
+ canvasEntered,
290
432
  save,
291
433
  flush,
292
434
  replaceDraft,
293
435
  canvasNonce,
436
+ canvasChange,
294
437
  presence,
295
438
  };
296
439
  }