@panphora/clayjs 1.25.1 → 1.26.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 (105) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +1141 -345
  11. package/dist/m/c-2EPHRF47.js +1 -0
  12. package/dist/m/{c-2GB34XL4.js → c-2SUFMUZX.js} +2 -2
  13. package/dist/m/{c-BHLYQGTA.js → c-3S3647J6.js} +1 -1
  14. package/dist/m/{c-QL4NYM2L.js → c-3S5TNRZA.js} +1 -1
  15. package/dist/m/{c-3Y234AUB.js → c-3XGLJAS7.js} +2 -2
  16. package/dist/m/{c-O4YIDULY.js → c-42WBOKFM.js} +1 -1
  17. package/dist/m/{c-42RLKGEY.js → c-4TP3UXBT.js} +1 -1
  18. package/dist/m/{c-ASGHHLFX.js → c-5ELELG2F.js} +1 -1
  19. package/dist/m/{c-T5IWJOMW.js → c-5ZYLTN3Q.js} +1 -1
  20. package/dist/m/{c-DDCYVLGG.js → c-67TBU56I.js} +1 -1
  21. package/dist/m/{c-WNOLMHI2.js → c-6BGJAJ67.js} +1 -1
  22. package/dist/m/{c-XS7G6U3Z.js → c-ABOBD7F3.js} +1 -1
  23. package/dist/m/{c-5IIEOPIV.js → c-ARO6CPMA.js} +1 -1
  24. package/dist/m/{c-XUZMRQ7O.js → c-BCS3AZQZ.js} +1 -1
  25. package/dist/m/c-BOS7GONO.js +1 -0
  26. package/dist/m/{c-NICICT4P.js → c-DEYHCYJB.js} +1 -1
  27. package/dist/m/{c-T7LOTEWO.js → c-DZT6W2NG.js} +1 -1
  28. package/dist/m/c-EHJWIGWN.js +1 -0
  29. package/dist/m/{c-V2V5FKXX.js → c-EIUKME3W.js} +1 -1
  30. package/dist/m/{c-ASMLERFA.js → c-EXZOA6XY.js} +1 -1
  31. package/dist/m/{c-STAPQ2X6.js → c-EYFMAR3M.js} +1 -1
  32. package/dist/m/{c-DVMDYRAE.js → c-FUN6AKFE.js} +1 -1
  33. package/dist/m/{c-XMIDHEKF.js → c-GY36NO7C.js} +1 -1
  34. package/dist/m/{c-TWB5ZQU4.js → c-H47C6PJZ.js} +1 -1
  35. package/dist/m/c-KD2ME5UN.js +2 -0
  36. package/dist/m/{c-GO635CRU.js → c-LAKBBJOU.js} +2 -2
  37. package/dist/m/{c-GNFDX6SU.js → c-LETPPG2F.js} +1 -1
  38. package/dist/m/{c-LS7IVQVW.js → c-MMV5JULA.js} +1 -1
  39. package/dist/m/{c-NEQRFC4O.js → c-MVVEIJUU.js} +1 -1
  40. package/dist/m/{c-XCOQNISP.js → c-N362HNGO.js} +1 -1
  41. package/dist/m/{c-7K4SCQB7.js → c-ND3W7D5U.js} +1 -1
  42. package/dist/m/{c-4NAYDOWH.js → c-NNHNXD6M.js} +1 -1
  43. package/dist/m/{c-DAUQ6JZ3.js → c-NYJ577NL.js} +1 -1
  44. package/dist/m/{c-3AAQJAKJ.js → c-PWILUN4T.js} +1 -1
  45. package/dist/m/{c-7Z2625LX.js → c-QA3Q3MDI.js} +1 -1
  46. package/dist/m/{c-2A2ATCIH.js → c-QKOKMQXA.js} +1 -1
  47. package/dist/m/{c-TGIMBAWE.js → c-RBWVF4N5.js} +1 -1
  48. package/dist/m/{c-A5IYL7JI.js → c-SPOEDIMP.js} +1 -1
  49. package/dist/m/{c-ZJ7726FO.js → c-SRW5OBCF.js} +1 -1
  50. package/dist/m/{c-A3OKSYRI.js → c-SYOA4T7V.js} +1 -1
  51. package/dist/m/{c-MHTO3QAD.js → c-TJ5PFOJ2.js} +1 -1
  52. package/dist/m/{c-NW4ZFKCY.js → c-TKV273YU.js} +1 -1
  53. package/dist/m/{c-D4N4UJCQ.js → c-UEKJWXCB.js} +2 -2
  54. package/dist/m/{c-44WITKWQ.js → c-V4DBV5ZV.js} +1 -1
  55. package/dist/m/c-WHMZCRQD.js +6 -0
  56. package/dist/m/{c-WMRJKQ2V.js → c-WHQUZM2V.js} +1 -1
  57. package/dist/m/{c-C27EVUQ6.js → c-XHD7XJTE.js} +1 -1
  58. package/dist/m/{c-PP3CUF3U.js → c-XONFK3WC.js} +1 -1
  59. package/dist/m/c-YYIN4NAF.js +30 -0
  60. package/dist/m/c-ZFAPQQIB.js +1 -0
  61. package/dist/m/core/{conflict-notice-Y2NTMDGJ.js → conflict-notice-UNDOD4BL.js} +1 -1
  62. package/dist/m/core/site-edits-C22O53GS.js +1 -0
  63. package/dist/m/loader-DCKCZKY6.js +1 -0
  64. package/dist/m/{manifest-A27A4278.js → manifest-7954CBE3.js} +1 -1
  65. package/dist/m/{manifest-edit-E8A61EB4.js → manifest-edit-93AA79D7.js} +1 -1
  66. package/dist/m/manifest.json +442 -441
  67. package/dist/m/page/{editor-HS2CAV3W.js → editor-UF3CIA6V.js} +2 -2
  68. package/dist/m/plugins/{cms-4KET6RZT.js → cms-KG6TCTR3.js} +1 -1
  69. package/dist/m/plugins/{live-CO4VJS46.js → live-DF5YS5AH.js} +1 -1
  70. package/dist/m/plugins/page-GZSU77JS.js +2 -0
  71. package/dist/m/plugins/{page-view-6KNTXABH.js → page-view-7MM3FEAR.js} +1 -1
  72. package/dist/m/plugins/{quickcrop-VNRXGRPC.js → quickcrop-UHURKA2F.js} +2 -2
  73. package/dist/m/plugins/richclay-MXH2GFAZ.js +549 -0
  74. package/dist/m/plugins/undo-KLHAGV75.js +1 -0
  75. package/dist/m/sync/{live-sync-DX4K5GDB.js → live-sync-RZQSDMZW.js} +1 -1
  76. package/dist/offline-manifest.json +360 -360
  77. package/package.json +1 -1
  78. package/src/core/site-edits.js +1 -1
  79. package/src/loader-logic.js +0 -13
  80. package/src/page/boot-pack.js +1 -0
  81. package/src/page/history-intent.js +111 -4
  82. package/src/page/ybind/bridge.js +14 -7
  83. package/src/page/ybind/moves.js +437 -3
  84. package/src/page/ybind/origins.js +1 -1
  85. package/src/page/ybind/recovery.js +1 -1
  86. package/src/plugins/page.js +48 -4
  87. package/src/plugins/richclay.js +136 -0
  88. package/src/plugins/undo.js +169 -7
  89. package/src/recovery/undo.js +14 -7
  90. package/src/sync/live-sync.js +24 -0
  91. package/src/ui/vendor-skin.js +9 -4
  92. package/dist/m/c-3XNTQQEJ.js +0 -1
  93. package/dist/m/c-4OZZ7ENT.js +0 -30
  94. package/dist/m/c-D2OPWOWO.js +0 -2
  95. package/dist/m/c-LUBZU4AZ.js +0 -6
  96. package/dist/m/c-LYNLY3MR.js +0 -1
  97. package/dist/m/c-MLMJUNRV.js +0 -1
  98. package/dist/m/c-V5L6HWVL.js +0 -1
  99. package/dist/m/core/site-edits-TIJDPG6H.js +0 -1
  100. package/dist/m/loader-7ORQVRCK.js +0 -1
  101. package/dist/m/plugins/page-L3VKIQU2.js +0 -2
  102. package/dist/m/plugins/richclay-V6WKJTUF.js +0 -549
  103. package/dist/m/plugins/undo-WUK2NLBA.js +0 -1
  104. /package/dist/m/plugins/{quickcrop-VNRXGRPC.js.LEGAL.txt → quickcrop-UHURKA2F.js.LEGAL.txt} +0 -0
  105. /package/dist/m/plugins/{richclay-V6WKJTUF.js.LEGAL.txt → richclay-MXH2GFAZ.js.LEGAL.txt} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "1.25.1",
3
+ "version": "1.26.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT-0",
@@ -104,7 +104,7 @@ function takeCurrent(current, sent, refused) {
104
104
  if (refused.has(key) || !rule || !Object.hasOwn(rule, key) || live[key] !== sent[key]) continue;
105
105
  // Outside undo history and the edit observers, the way a sync frame lands: an undo
106
106
  // must not bring back the value this tab was behind on, and then declare it.
107
- const fields = new Map([...root.querySelectorAll("[editable]")].map((field) => [field, field.innerHTML]));
107
+ const fields = new Map([...root.querySelectorAll("[data-richclay], [richclay], [editable], [clay-editable]")].map((field) => [field, field.innerHTML]));
108
108
  Mutation.pause();
109
109
  try {
110
110
  engine.applyData(root, { site: { [key]: value } }, { site: { [key]: rule[key] } });
@@ -195,10 +195,6 @@ const IMPLIES = { cms: ["quickcrop", "upload", "menu"], "ai-edit": ["wire"], blo
195
195
  // A Document's editor owns its article's rich text, so richclay (for [editable]) has
196
196
  // nothing to do there; plugins=blockeditor,richclay still loads it.
197
197
  const DISPLACES = { blockeditor: ["richclay"] };
198
- // A Document's editor keeps its own history, the same one Cmd+Z in the editor uses, and
199
- // hyper-undo takes Cmd+Z for the whole window: loaded together the two would fight over
200
- // every press. This is the one pair where a name the page asked for by hand still loses.
201
- const CANNOT_RUN_WITH = { blockeditor: ["undo"] };
202
198
  // No first paint or keystroke waits for these. source keeps the file's bytes outside
203
199
  // the edited parts stable; a save before it installs is the ordinary full serialization.
204
200
  // upgrade only asks the host for a newer version once `clay.ready` resolves.
@@ -296,15 +292,6 @@ export function resolveModules(params, isEditMode, { isDraftMode = false, hasSap
296
292
  if (enabled.has(owner)) for (const name of names) if (!explicit.has(name)) enabled.delete(name);
297
293
  }
298
294
 
299
- for (const [owner, names] of Object.entries(CANNOT_RUN_WITH)) {
300
- if (!enabled.has(owner)) continue;
301
- for (const name of names) {
302
- if (!enabled.has(name)) continue;
303
- enabled.delete(name);
304
- console.warn(`clayjs: plugin "${name}" cannot run with "${owner}" and was left out`);
305
- }
306
- }
307
-
308
295
  const plugins = [];
309
296
  const late = [];
310
297
  for (const name of PLUGIN_ORDER) {
@@ -20,6 +20,7 @@ export { createYPageBinding } from "./ybind/binding.js";
20
20
  export { yValueOf } from "./ybind/provider.js";
21
21
  export { seedPageFromArticle } from "./ybind/migrate.js";
22
22
  export { holdSlashRange, heldSlashRange } from "./chrome.js";
23
+ export { createHistoryBridge } from "./history-intent.js";
23
24
  export { createRecoverList, focusBlock } from "./recover.js";
24
25
  export { observeDocumentTitle } from "./document-title-runtime.js";
25
26
  export { createFeatureLoader } from "./feature-loader.js";
@@ -1,5 +1,5 @@
1
- // Typing joins one undo item the way it does in other editors: Mod-z removes the last
2
- // word typed, not the last letter. Only the editor's own input path marks a
1
+ // Typing joins one undo item per sentence, the same as every other field on the page:
2
+ // Mod-z removes the last sentence typed, not the last letter or word. Only the editor's own input path marks a
3
3
  // transaction; a command or a programmatic dispatch stays one undo item by itself.
4
4
  import { state } from "../vendor/prosemirror.vendor.js";
5
5
  import { STEP } from "./blocks.js";
@@ -14,15 +14,28 @@ const blockIdAt = (doc, pos) => {
14
14
 
15
15
  const markKey = (st) => (st.storedMarks ?? st.selection.$from.marks()).map((m) => `${m.type.name}:${JSON.stringify(m.attrs)}`).sort().join(",");
16
16
 
17
+ // A sentence ends at a space typed right after ., ? or ! (closing quotes and brackets
18
+ // allowed between). The space belongs to the sentence it ends, so the step closes after it.
19
+ // A second space, or a space after a space, is not a new sentence. The rule is hyper-undo's
20
+ // `endsSentence`, kept here so the block editor does not load the engine.
21
+ const SENTENCE_END = /[.?!]["'\u201D\u2019)\]]*$/;
22
+
23
+ const endsSentence = (st, from, text) => {
24
+ if (!/\s$/.test(text)) return false;
25
+ const $from = st.doc.resolve(from);
26
+ const head = $from.parent.textBetween(0, $from.parentOffset) + text.replace(/\s+$/, "");
27
+ return !/\s$/.test(head) && SENTENCE_END.test(head);
28
+ };
29
+
17
30
  export function typingIntent(st, from, to, text) {
18
- return { kind: "insert", blockId: blockIdAt(st.doc, from), from, end: from + text.length, replaced: from !== to, text, marks: markKey(st), at: Date.now() };
31
+ return { kind: "insert", blockId: blockIdAt(st.doc, from), from, end: from + text.length, replaced: from !== to, text, marks: markKey(st), breakAfter: endsSentence(st, from, text), at: Date.now() };
19
32
  }
20
33
 
21
34
  export function continuesGroup(prev, next) {
22
35
  if (!prev || !next || prev.kind !== next.kind) return false;
23
36
  if (next.kind === "compose") return prev.id === next.id;
24
37
  if (prev.blockId !== next.blockId || next.at - prev.at > GROUP_MS) return false;
25
- if (next.kind === "insert") return !next.replaced && next.from === prev.end && next.marks === prev.marks && !(/\s$/.test(prev.text) && /^\S/.test(next.text));
38
+ if (next.kind === "insert") return !prev.breakAfter && !next.replaced && next.from === prev.end && next.marks === prev.marks;
26
39
  return next.direction === prev.direction && (next.direction < 0 ? next.to === prev.from : next.from === prev.from);
27
40
  }
28
41
 
@@ -76,3 +89,97 @@ export function historyIntentPlugin() {
76
89
  },
77
90
  });
78
91
  }
92
+
93
+ // Each page entry owns one current Yjs StackItem, replaced by its inverse after replay.
94
+ export function createHistoryBridge({ um, undo, redo, article, focus }) {
95
+ const page = () => window.clay?.undo
96
+ const epoch = Math.random().toString(36).slice(2)
97
+ let next = 0
98
+ let running = false
99
+ let recording = false
100
+ let disposed = false
101
+
102
+ const replay = (entry, direction) => {
103
+ const fromKey = direction === 'undo' ? 'undoStack' : 'redoStack'
104
+ const toKey = direction === 'undo' ? 'redoStack' : 'undoStack'
105
+ const source = um[fromKey], destination = um[toKey]
106
+ const index = source.indexOf(entry.item)
107
+ if (index < 0) throw new Error('blockeditor: history item is no longer available')
108
+ const remaining = source.filter((_, at) => at !== index)
109
+ // Yjs scans BOTH stacks' deletion sets when checking map conflicts. Keep those
110
+ // sets visible on the opposite stack while limiting the pop loop to one item.
111
+ const temporary = [...destination, ...remaining]
112
+ const boundary = temporary.length
113
+ um[fromKey] = [entry.item]
114
+ um[toKey] = temporary
115
+ running = true
116
+ let changed
117
+ let inverse
118
+ try {
119
+ changed = direction === 'undo' ? undo() : redo()
120
+ } finally {
121
+ inverse = temporary.slice(boundary)
122
+ um[fromKey] = remaining
123
+ um[toKey] = [...destination, ...inverse]
124
+ running = false
125
+ um.stopCapturing()
126
+ }
127
+ if (!changed || !inverse.length) {
128
+ entry.item = null
129
+ throw new Error('blockeditor: history item was emptied by another edit')
130
+ }
131
+ entry.item = inverse[0]
132
+ entry.applied = direction === 'redo'
133
+ }
134
+
135
+ const onAdded = (event) => {
136
+ if (running || event.origin === um || event.type !== 'undo' || page()?.pageHistory !== true) return
137
+ const entry = { item: event.stackItem, applied: true }
138
+ const id = `${epoch}:${++next}`
139
+ const oldValue = `${id}:undone`, newValue = `${id}:applied`
140
+ const primitive = {
141
+ kind: 'value',
142
+ target: article,
143
+ prop: `blockeditor-step:${id}`,
144
+ oldValue,
145
+ newValue,
146
+ read: () => !disposed && entry.item && (entry.applied ? um.undoStack : um.redoStack).includes(entry.item)
147
+ ? entry.applied ? newValue : oldValue
148
+ : null,
149
+ write: (target, wanted) => {
150
+ const typing = !!document.activeElement?.closest?.('[contenteditable]:not([contenteditable="false"])')
151
+ const before = article.textContent
152
+ replay(entry, wanted === oldValue ? 'undo' : 'redo')
153
+ if (typing && article.textContent !== before && typeof focus === 'function') focus()
154
+ },
155
+ }
156
+ recording = true
157
+ try {
158
+ try { page().recordStep('Edit', primitive) } catch { page().record(primitive) }
159
+ } finally {
160
+ recording = false
161
+ }
162
+ }
163
+
164
+ const onCommit = () => { if (!recording) um.stopCapturing() }
165
+ um.on('stack-item-added', onAdded)
166
+ let subscribed = null
167
+ const subscribe = () => {
168
+ const current = page()?.pageHistory === true ? page() : null
169
+ if (current === subscribed) return
170
+ subscribed?.off?.('commit', onCommit)
171
+ subscribed = current
172
+ subscribed?.on?.('commit', onCommit)
173
+ }
174
+ subscribe()
175
+ document.addEventListener('clay:undo-session', subscribe)
176
+
177
+ return {
178
+ dispose() {
179
+ disposed = true
180
+ um.off('stack-item-added', onAdded)
181
+ subscribed?.off?.('commit', onCommit)
182
+ document.removeEventListener('clay:undo-session', subscribe)
183
+ },
184
+ }
185
+ }
@@ -10,7 +10,7 @@ import { state as pmState } from "../../vendor/prosemirror.vendor.js";
10
10
  import { repairSiblings, isKey } from "../model.js";
11
11
  import { createOriginIndex, installUndoOrigins } from "./origins.js";
12
12
  import { reduceRecovery, reduceRecoverable, canonicalRevision, restoreKey, physicalKeyOf, authorKey, RECOVERED_PREFIX, parsePhysicalKey, parseGoneIntents } from "./recovery.js";
13
- import { moveBlocks } from "./moves.js";
13
+ import { moveBlocks, createMoveCarry } from "./moves.js";
14
14
  import { createBoardCommands } from "./board-commands.js";
15
15
  import { continuesGroup, intentOf } from "../history-intent.js";
16
16
  import { MOVE, BlockSelection } from "../blocks.js";
@@ -320,7 +320,9 @@ export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], author
320
320
  if (!d) { adopt(view.state.doc); return; }
321
321
  const tr = view.state.tr.replace(d.start, d.endA, derived.slice(d.start, d.endB)).setMeta(REMOTE_META, true).setMeta("addToHistory", false);
322
322
  if (own) tr.setMeta("yundo", true);
323
- if (keep) {
323
+ if (own && view.state.doc.textContent !== derived.textContent && document.activeElement?.closest?.('[contenteditable]:not([contenteditable="false"])')) {
324
+ tr.setSelection(pmState.Selection.near(tr.doc.resolve(Math.min(d.endB, tr.doc.content.size)), -1));
325
+ } else if (keep) {
324
326
  const hiddenUnit = (y) => y >= 0 && w.holes.some((hole) => y >= yBefore(w.holes, hole.at) && y < yAfter(w.holes, hole.at));
325
327
  const placeIn = (id, offset) => {
326
328
  let found = null;
@@ -359,12 +361,14 @@ export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], author
359
361
  return true;
360
362
  };
361
363
  const stale = (t) => { began.set(t, docVersion); docVersion++; };
364
+ const carry = createMoveCarry({ pd, origins, um, walked: () => currentWalk() ?? walk(pd, origins) });
362
365
  const onTransaction = (t) => {
363
366
  if (unchanged(t)) {
364
367
  idle.add(t);
365
368
  if (walkedAt === began.get(t) && docVersion === walkedAt + 1) walkedAt = docVersion;
366
369
  return;
367
370
  }
371
+ if (t.origin === um) carry.note(t);
368
372
  deltas++;
369
373
  delta = deltas === 1 ? captureDelta(pd, t, w) : null;
370
374
  };
@@ -385,7 +389,9 @@ export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], author
385
389
  saved = { anchor: end(sel.$anchor), head: end(sel.$head) };
386
390
  };
387
391
  const onAfter = (t) => {
388
- if (t.origin !== LOCAL) { lastIntent = null; groupOpen = false; }
392
+ // A transaction that wrote nothing (Yjs's cleanup after an undo) is no peer's edit, so
393
+ // it leaves an open typing run open.
394
+ if (t.origin !== LOCAL && !unchanged(t)) { lastIntent = null; groupOpen = false; }
389
395
  };
390
396
  // One render per batch Yjs settles (a transaction and those its observers queued), after
391
397
  // its garbage collection and merges, so the walk it leaves is current for the next keystroke.
@@ -398,9 +404,10 @@ export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], author
398
404
  else if (t.origin === um) own = true;
399
405
  }
400
406
  if (!changed) { saved = null; return; }
401
- if (!view) { bumpRecovery(); return; }
407
+ if (!view) { bumpRecovery(); carry.received(transactions); return; }
402
408
  if (local) { saved = null; return; }
403
409
  render(own);
410
+ carry.received(transactions);
404
411
  };
405
412
  const portMove = (tr, move) => {
406
413
  const result = moveBlocks({ pd, view, um, origins, render, walked: currentWalk() }, move);
@@ -486,7 +493,7 @@ export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], author
486
493
  registerAuthor = null;
487
494
  const intent = history ? intentOf(local, view) : null;
488
495
  const join = groupOpen && continuesGroup(lastIntent, intent);
489
- lastIntent = intent;
496
+ lastIntent = !join && intent?.breakAfter && !intent.text.trim() ? { ...intent, breakAfter: false } : intent;
490
497
  joining = join;
491
498
  try {
492
499
  localGroup(pd, local, w, stats, um, history, register, { join, open: intent !== null });
@@ -546,7 +553,7 @@ export function createBridge({ pd, onNotice = () => {}, ownedTokens = [], author
546
553
  moveBlocks(args) {
547
554
  return moveBlocks({ pd, view, um, origins, render, walked: currentWalk() }, args);
548
555
  },
549
- undo() { return um.undo() !== null; },
550
- redo() { return um.redo() !== null; },
556
+ undo() { return carry.replay("undo"); },
557
+ redo() { return carry.replay("redo"); },
551
558
  };
552
559
  }