@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.
- package/dist/all.js +1 -1
- package/dist/clay-dom.js +1 -1
- package/dist/clay-events.js +1 -1
- package/dist/clay-internals.js +1 -1
- package/dist/clay-options.js +1 -1
- package/dist/clay-router.js +1 -1
- package/dist/clay-ui.js +1 -1
- package/dist/clay-utils.js +1 -1
- package/dist/clay.js +1 -1
- package/dist/clay.standalone.js +1141 -345
- package/dist/m/c-2EPHRF47.js +1 -0
- package/dist/m/{c-2GB34XL4.js → c-2SUFMUZX.js} +2 -2
- package/dist/m/{c-BHLYQGTA.js → c-3S3647J6.js} +1 -1
- package/dist/m/{c-QL4NYM2L.js → c-3S5TNRZA.js} +1 -1
- package/dist/m/{c-3Y234AUB.js → c-3XGLJAS7.js} +2 -2
- package/dist/m/{c-O4YIDULY.js → c-42WBOKFM.js} +1 -1
- package/dist/m/{c-42RLKGEY.js → c-4TP3UXBT.js} +1 -1
- package/dist/m/{c-ASGHHLFX.js → c-5ELELG2F.js} +1 -1
- package/dist/m/{c-T5IWJOMW.js → c-5ZYLTN3Q.js} +1 -1
- package/dist/m/{c-DDCYVLGG.js → c-67TBU56I.js} +1 -1
- package/dist/m/{c-WNOLMHI2.js → c-6BGJAJ67.js} +1 -1
- package/dist/m/{c-XS7G6U3Z.js → c-ABOBD7F3.js} +1 -1
- package/dist/m/{c-5IIEOPIV.js → c-ARO6CPMA.js} +1 -1
- package/dist/m/{c-XUZMRQ7O.js → c-BCS3AZQZ.js} +1 -1
- package/dist/m/c-BOS7GONO.js +1 -0
- package/dist/m/{c-NICICT4P.js → c-DEYHCYJB.js} +1 -1
- package/dist/m/{c-T7LOTEWO.js → c-DZT6W2NG.js} +1 -1
- package/dist/m/c-EHJWIGWN.js +1 -0
- package/dist/m/{c-V2V5FKXX.js → c-EIUKME3W.js} +1 -1
- package/dist/m/{c-ASMLERFA.js → c-EXZOA6XY.js} +1 -1
- package/dist/m/{c-STAPQ2X6.js → c-EYFMAR3M.js} +1 -1
- package/dist/m/{c-DVMDYRAE.js → c-FUN6AKFE.js} +1 -1
- package/dist/m/{c-XMIDHEKF.js → c-GY36NO7C.js} +1 -1
- package/dist/m/{c-TWB5ZQU4.js → c-H47C6PJZ.js} +1 -1
- package/dist/m/c-KD2ME5UN.js +2 -0
- package/dist/m/{c-GO635CRU.js → c-LAKBBJOU.js} +2 -2
- package/dist/m/{c-GNFDX6SU.js → c-LETPPG2F.js} +1 -1
- package/dist/m/{c-LS7IVQVW.js → c-MMV5JULA.js} +1 -1
- package/dist/m/{c-NEQRFC4O.js → c-MVVEIJUU.js} +1 -1
- package/dist/m/{c-XCOQNISP.js → c-N362HNGO.js} +1 -1
- package/dist/m/{c-7K4SCQB7.js → c-ND3W7D5U.js} +1 -1
- package/dist/m/{c-4NAYDOWH.js → c-NNHNXD6M.js} +1 -1
- package/dist/m/{c-DAUQ6JZ3.js → c-NYJ577NL.js} +1 -1
- package/dist/m/{c-3AAQJAKJ.js → c-PWILUN4T.js} +1 -1
- package/dist/m/{c-7Z2625LX.js → c-QA3Q3MDI.js} +1 -1
- package/dist/m/{c-2A2ATCIH.js → c-QKOKMQXA.js} +1 -1
- package/dist/m/{c-TGIMBAWE.js → c-RBWVF4N5.js} +1 -1
- package/dist/m/{c-A5IYL7JI.js → c-SPOEDIMP.js} +1 -1
- package/dist/m/{c-ZJ7726FO.js → c-SRW5OBCF.js} +1 -1
- package/dist/m/{c-A3OKSYRI.js → c-SYOA4T7V.js} +1 -1
- package/dist/m/{c-MHTO3QAD.js → c-TJ5PFOJ2.js} +1 -1
- package/dist/m/{c-NW4ZFKCY.js → c-TKV273YU.js} +1 -1
- package/dist/m/{c-D4N4UJCQ.js → c-UEKJWXCB.js} +2 -2
- package/dist/m/{c-44WITKWQ.js → c-V4DBV5ZV.js} +1 -1
- package/dist/m/c-WHMZCRQD.js +6 -0
- package/dist/m/{c-WMRJKQ2V.js → c-WHQUZM2V.js} +1 -1
- package/dist/m/{c-C27EVUQ6.js → c-XHD7XJTE.js} +1 -1
- package/dist/m/{c-PP3CUF3U.js → c-XONFK3WC.js} +1 -1
- package/dist/m/c-YYIN4NAF.js +30 -0
- package/dist/m/c-ZFAPQQIB.js +1 -0
- package/dist/m/core/{conflict-notice-Y2NTMDGJ.js → conflict-notice-UNDOD4BL.js} +1 -1
- package/dist/m/core/site-edits-C22O53GS.js +1 -0
- package/dist/m/loader-DCKCZKY6.js +1 -0
- package/dist/m/{manifest-A27A4278.js → manifest-7954CBE3.js} +1 -1
- package/dist/m/{manifest-edit-E8A61EB4.js → manifest-edit-93AA79D7.js} +1 -1
- package/dist/m/manifest.json +442 -441
- package/dist/m/page/{editor-HS2CAV3W.js → editor-UF3CIA6V.js} +2 -2
- package/dist/m/plugins/{cms-4KET6RZT.js → cms-KG6TCTR3.js} +1 -1
- package/dist/m/plugins/{live-CO4VJS46.js → live-DF5YS5AH.js} +1 -1
- package/dist/m/plugins/page-GZSU77JS.js +2 -0
- package/dist/m/plugins/{page-view-6KNTXABH.js → page-view-7MM3FEAR.js} +1 -1
- package/dist/m/plugins/{quickcrop-VNRXGRPC.js → quickcrop-UHURKA2F.js} +2 -2
- package/dist/m/plugins/richclay-MXH2GFAZ.js +549 -0
- package/dist/m/plugins/undo-KLHAGV75.js +1 -0
- package/dist/m/sync/{live-sync-DX4K5GDB.js → live-sync-RZQSDMZW.js} +1 -1
- package/dist/offline-manifest.json +360 -360
- package/package.json +1 -1
- package/src/core/site-edits.js +1 -1
- package/src/loader-logic.js +0 -13
- package/src/page/boot-pack.js +1 -0
- package/src/page/history-intent.js +111 -4
- package/src/page/ybind/bridge.js +14 -7
- package/src/page/ybind/moves.js +437 -3
- package/src/page/ybind/origins.js +1 -1
- package/src/page/ybind/recovery.js +1 -1
- package/src/plugins/page.js +48 -4
- package/src/plugins/richclay.js +136 -0
- package/src/plugins/undo.js +169 -7
- package/src/recovery/undo.js +14 -7
- package/src/sync/live-sync.js +24 -0
- package/src/ui/vendor-skin.js +9 -4
- package/dist/m/c-3XNTQQEJ.js +0 -1
- package/dist/m/c-4OZZ7ENT.js +0 -30
- package/dist/m/c-D2OPWOWO.js +0 -2
- package/dist/m/c-LUBZU4AZ.js +0 -6
- package/dist/m/c-LYNLY3MR.js +0 -1
- package/dist/m/c-MLMJUNRV.js +0 -1
- package/dist/m/c-V5L6HWVL.js +0 -1
- package/dist/m/core/site-edits-TIJDPG6H.js +0 -1
- package/dist/m/loader-7ORQVRCK.js +0 -1
- package/dist/m/plugins/page-L3VKIQU2.js +0 -2
- package/dist/m/plugins/richclay-V6WKJTUF.js +0 -549
- package/dist/m/plugins/undo-WUK2NLBA.js +0 -1
- /package/dist/m/plugins/{quickcrop-VNRXGRPC.js.LEGAL.txt → quickcrop-UHURKA2F.js.LEGAL.txt} +0 -0
- /package/dist/m/plugins/{richclay-V6WKJTUF.js.LEGAL.txt → richclay-MXH2GFAZ.js.LEGAL.txt} +0 -0
package/src/page/ybind/moves.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import * as Y from "../../vendor/yjs.vendor.js";
|
|
2
|
-
import { LOCAL, REMOTE_META, mintOp } from "./layout.js";
|
|
2
|
+
import { LOCAL, REMOTE_META, idKey, mintOp } from "./layout.js";
|
|
3
3
|
import { state as pmState } from "../../vendor/prosemirror.vendor.js";
|
|
4
4
|
import { walk } from "./walk.js";
|
|
5
5
|
import { docOf } from "./doc.js";
|
|
6
6
|
import { createProjectionMap } from "./projection-map.js";
|
|
7
7
|
import { beforeFence } from "./writer.js";
|
|
8
|
-
import { unitIds } from "./origins.js";
|
|
9
|
-
import { attrKey, copyActiveKey, parseGoneIntents, physicalKeyOf, recoveredRoot, removeKey, restoreKey } from "./recovery.js";
|
|
8
|
+
import { coversOriginal, unitIds } from "./origins.js";
|
|
9
|
+
import { COPY_ACTIVE, REMOVE, TEXT_DELETE, attrKey, copyActiveKey, parseGoneIntents, parsePhysicalKey, physicalKeyOf, recoveredRoot, removeKey, restoreKey, visibleOriginIndex } from "./recovery.js";
|
|
10
10
|
|
|
11
11
|
const refuse = (code) => ({ status: "refused", code });
|
|
12
12
|
|
|
@@ -298,3 +298,437 @@ export function moveBlocks({ pd, view, um, origins, render, walked = null }, arg
|
|
|
298
298
|
restoreCaret();
|
|
299
299
|
return { status: "moved", ids: movedIds };
|
|
300
300
|
}
|
|
301
|
+
|
|
302
|
+
const CARRY = "blockeditor-move-carry";
|
|
303
|
+
|
|
304
|
+
const COPY_ACTIVE_KEYS = `["${COPY_ACTIVE}",`;
|
|
305
|
+
const REMOVE_KEYS = `["${REMOVE}",`;
|
|
306
|
+
const TEXT_DELETE_KEYS = `["${TEXT_DELETE}",`;
|
|
307
|
+
|
|
308
|
+
const startOf = (pd, sourceKey) => {
|
|
309
|
+
const id = parsePhysicalKey(sourceKey);
|
|
310
|
+
if (!id) return null;
|
|
311
|
+
let it = null;
|
|
312
|
+
try { it = Y.getItem(pd.ydoc.store, id); } catch { return null; }
|
|
313
|
+
return it instanceof Y.Item && !it.deleted && it.parent === pd.flow ? it : null;
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const targetOf = (pd, origins, walked, map, blockId) => {
|
|
317
|
+
let cursor = 0, seg = null;
|
|
318
|
+
for (const candidate of walked.visible) {
|
|
319
|
+
if (candidate.id === blockId) { seg = candidate; break; }
|
|
320
|
+
cursor += candidate.len;
|
|
321
|
+
}
|
|
322
|
+
if (!seg || seg.implicit || seg.atom || seg.opaque || !seg.brk) return null;
|
|
323
|
+
const places = new Map();
|
|
324
|
+
let head = null;
|
|
325
|
+
for (const [from, to] of map.physicalRanges(cursor, cursor + seg.len)) {
|
|
326
|
+
const ids = unitIds(pd.flow, from, to);
|
|
327
|
+
for (let i = 0; i < ids.length; i++) {
|
|
328
|
+
if (head === null) head = from;
|
|
329
|
+
else places.set(idKey(origins.resolve(ids[i])), from + i);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return head === null ? null : { head, places };
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const formatsBefore = (pd, stop) => {
|
|
336
|
+
const out = new Map();
|
|
337
|
+
for (let it = pd.flow._start; it && it !== stop; it = it.right) {
|
|
338
|
+
if (it.deleted || !(it.content instanceof Y.ContentFormat)) continue;
|
|
339
|
+
if (it.content.value === null) out.delete(it.content.key);
|
|
340
|
+
else out.set(it.content.key, it.content.value);
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const rangesOf = (ranges) => {
|
|
346
|
+
const set = Y.createDeleteSet();
|
|
347
|
+
for (const { client, clock, len } of ranges) (set.clients.get(client) ?? set.clients.set(client, []).get(client)).push({ clock, len });
|
|
348
|
+
return set;
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const markerOf = (it) => it.content instanceof Y.ContentEmbed && it.content.embed?.b !== undefined;
|
|
352
|
+
|
|
353
|
+
// The block a unit was typed into: the first block marker on the chain of left neighbours
|
|
354
|
+
// each unit was inserted after. The chain never changes, so every tab finds the same block,
|
|
355
|
+
// and a word has one home copy whose owner alone may carry it.
|
|
356
|
+
const homeOf = (pd, homes, it) => {
|
|
357
|
+
const path = [];
|
|
358
|
+
let at = it, home = null;
|
|
359
|
+
for (;;) {
|
|
360
|
+
const known = homes.get(at);
|
|
361
|
+
if (known !== undefined) { home = known; break; }
|
|
362
|
+
path.push(at);
|
|
363
|
+
let next = null;
|
|
364
|
+
if (at.origin) try { next = Y.getItem(pd.ydoc.store, at.origin); } catch { next = null; }
|
|
365
|
+
if (!(next instanceof Y.Item)) break;
|
|
366
|
+
if (markerOf(next)) { home = next; break; }
|
|
367
|
+
at = next;
|
|
368
|
+
}
|
|
369
|
+
for (const item of path) homes.set(item, home);
|
|
370
|
+
return home;
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
// Only a copy's own words move, only deletions a person's record backs are mirrored, and the
|
|
374
|
+
// words join the history step that shows the region again, so that step puts each word back
|
|
375
|
+
// exactly where it was typed. A region a Redo hid again is only mirrored: what reached it
|
|
376
|
+
// since the Undo is set aside, as before the carry existed.
|
|
377
|
+
function carryAcross({ pd, origins, walked, placed }, owned, homes, flip = null, records = false) {
|
|
378
|
+
const entries = walked.hidden.filter((entry) => owned.has(entry.key));
|
|
379
|
+
if (!entries.length) return false;
|
|
380
|
+
const self = pd.ydoc.clientID;
|
|
381
|
+
const deletes = flip !== null || records ? parseGoneIntents(pd.gone).textDeletes.filter((record) => record.actor !== self) : [];
|
|
382
|
+
const removed = new Map();
|
|
383
|
+
const taken = new Set();
|
|
384
|
+
const inserts = [];
|
|
385
|
+
const folds = new Map();
|
|
386
|
+
let visible = null, map = null;
|
|
387
|
+
for (const entry of entries) {
|
|
388
|
+
const owner = owned.get(entry.key);
|
|
389
|
+
const { left, past } = owner;
|
|
390
|
+
const start = startOf(pd, entry.sourceKey);
|
|
391
|
+
if (!start) continue;
|
|
392
|
+
const live = new Map(entry.items.map((item) => [item.id, item]));
|
|
393
|
+
const region = [];
|
|
394
|
+
for (let it = start.right; it; it = it.right) {
|
|
395
|
+
if (it.countable && !it.deleted && !live.has(idKey(it.id))) break;
|
|
396
|
+
region.push(it);
|
|
397
|
+
}
|
|
398
|
+
const words = new Set(owner.copy ? region.filter((it) => !it.deleted && it.countable && !markerOf(it) && !left.has(`${idKey(it.id)}+${it.length}`) && homeOf(pd, homes, it) === start) : []);
|
|
399
|
+
const covering = deletes.filter((record) => region.some((it) => it.deleted && it.countable && it.id.client === record.client && record.clock < it.id.clock + it.length && it.id.clock < record.clock + record.length));
|
|
400
|
+
if (!words.size && !covering.length) continue;
|
|
401
|
+
const seen = owner.seen ?? new Map(entry.sv);
|
|
402
|
+
const fresh = (client, clock) => clock >= (seen.get(client) ?? 0);
|
|
403
|
+
const formats = words.size ? formatsBefore(pd, start) : null;
|
|
404
|
+
let attrs = null;
|
|
405
|
+
const units = [];
|
|
406
|
+
const shown = new Map();
|
|
407
|
+
for (const it of region) {
|
|
408
|
+
if (!it.countable) {
|
|
409
|
+
if (formats && it.content instanceof Y.ContentFormat && (!it.deleted || Y.isDeleted(past, it.id))) {
|
|
410
|
+
if (it.content.value === null) formats.delete(it.content.key);
|
|
411
|
+
else formats.set(it.content.key, it.content.value);
|
|
412
|
+
attrs = null;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const item = it.deleted ? null : live.get(idKey(it.id));
|
|
417
|
+
const word = words.has(it);
|
|
418
|
+
for (let offset = 0; offset < it.length; offset++) {
|
|
419
|
+
const id = { client: it.id.client, clock: it.id.clock + offset };
|
|
420
|
+
const source = origins.resolve(id);
|
|
421
|
+
const key = idKey(source);
|
|
422
|
+
if (!(it.deleted && covering.length && coversOriginal(covering, self, id))) shown.set(key, true);
|
|
423
|
+
else if (!shown.has(key) && (!owner.alive || fresh(id.client, id.clock) || owner.alive.has(idKey(id)))) shown.set(key, false);
|
|
424
|
+
units.push({ it, item, offset, id, source, key, attrs: word ? (attrs ??= Object.fromEntries(formats)) : null });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
map ??= createProjectionMap(walked, pd.flow.length);
|
|
428
|
+
const target = targetOf(pd, origins, walked, map, entry.blockId);
|
|
429
|
+
if (!target) {
|
|
430
|
+
for (const it of words) left.add(`${idKey(it.id)}+${it.length}`);
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
if (covering.length) for (const [key, place] of target.places) if (shown.get(key) === false && !removed.has(place)) removed.set(place, null);
|
|
434
|
+
const groups = new Map();
|
|
435
|
+
let at = target.head + 1;
|
|
436
|
+
for (const unit of units) {
|
|
437
|
+
const { it, item, offset, id, source } = unit;
|
|
438
|
+
if (unit.attrs) {
|
|
439
|
+
if (!taken.has(idKey(id)) && !(visible ??= visibleOriginIndex(pd.flow, walked, origins))(source.client, source.clock)) {
|
|
440
|
+
taken.add(idKey(id));
|
|
441
|
+
removed.set(item.at + offset, owner);
|
|
442
|
+
if (owner.step && !folds.has(owner)) folds.set(owner, { deleted: [], inserted: [] });
|
|
443
|
+
const group = groups.get(at) ?? groups.set(at, []).get(at);
|
|
444
|
+
group.push(item.kind === "text" ? { ch: it.content.str[offset], attrs: unit.attrs, source } : { embed: it.content.embed, attrs: unit.attrs, source });
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
left.add(`${idKey(it.id)}+${it.length}`);
|
|
448
|
+
}
|
|
449
|
+
const place = target.places.get(unit.key);
|
|
450
|
+
if (place !== undefined) at = place + 1;
|
|
451
|
+
}
|
|
452
|
+
for (const [place, list] of groups) inserts.push({ at: place, units: list, owner });
|
|
453
|
+
}
|
|
454
|
+
if (!removed.size && !inserts.length) return false;
|
|
455
|
+
const ops = [...[...removed].map(([at, owner]) => ({ at, owner })), ...inserts].sort((a, b) => b.at - a.at || (a.units ? 1 : 0) - (b.units ? 1 : 0));
|
|
456
|
+
pd.ydoc.transact((tr) => {
|
|
457
|
+
let run = null;
|
|
458
|
+
// Deleting a word also deletes the format items that only it needed. The step that
|
|
459
|
+
// brings the word back has to bring those back with it, so it keeps all of them.
|
|
460
|
+
const cut = () => {
|
|
461
|
+
if (!run) return;
|
|
462
|
+
const fold = folds.get(run.owner);
|
|
463
|
+
const sizes = fold ? new Map([...tr.deleteSet.clients].map(([client, ranges]) => [client, ranges.length])) : null;
|
|
464
|
+
pd.flow.delete(run.from, run.to - run.from);
|
|
465
|
+
if (fold) for (const [client, ranges] of tr.deleteSet.clients) for (const { clock, len } of ranges.slice(sizes.get(client) ?? 0)) {
|
|
466
|
+
const struct = Y.getItem(pd.ydoc.store, { client, clock });
|
|
467
|
+
if (struct instanceof Y.Item) struct.keep = true;
|
|
468
|
+
fold.deleted.push({ client, clock, len });
|
|
469
|
+
}
|
|
470
|
+
run = null;
|
|
471
|
+
};
|
|
472
|
+
for (const op of ops) {
|
|
473
|
+
if (!op.units) {
|
|
474
|
+
if (run && op.at === run.from - 1 && op.owner === run.owner) run.from = op.at;
|
|
475
|
+
else { cut(); run = { from: op.at, to: op.at + 1, owner: op.owner }; }
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
cut();
|
|
479
|
+
const before = Y.getState(pd.ydoc.store, self);
|
|
480
|
+
let cursor = op.at, text = "", attrs = null;
|
|
481
|
+
const put = () => {
|
|
482
|
+
if (!text) return;
|
|
483
|
+
const chars = text;
|
|
484
|
+
beforeFence(pd, cursor, walked.fences, () => pd.flow.insert(cursor, chars, { ...attrs }));
|
|
485
|
+
cursor += chars.length;
|
|
486
|
+
text = "";
|
|
487
|
+
};
|
|
488
|
+
for (const unit of op.units) {
|
|
489
|
+
if (unit.ch === undefined) {
|
|
490
|
+
put();
|
|
491
|
+
beforeFence(pd, cursor, walked.fences, () => pd.flow.insertEmbed(cursor, { ...unit.embed }, { ...unit.attrs }));
|
|
492
|
+
cursor += 1;
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (text && attrsKey(unit.attrs) !== attrsKey(attrs)) put();
|
|
496
|
+
attrs = unit.attrs;
|
|
497
|
+
text += unit.ch;
|
|
498
|
+
}
|
|
499
|
+
put();
|
|
500
|
+
folds.get(op.owner)?.inserted.push({ client: self, clock: before, len: Y.getState(pd.ydoc.store, self) - before });
|
|
501
|
+
const dest = unitIds(pd.flow, op.at, cursor);
|
|
502
|
+
if (dest.length !== op.units.length) throw new Error(`Carried ${dest.length} units instead of ${op.units.length}`);
|
|
503
|
+
origins.recordSpans(dest.map((id, i) => ({ dest: id, source: op.units[i].source })));
|
|
504
|
+
placed.push({ ids: dest, step: op.owner.step });
|
|
505
|
+
}
|
|
506
|
+
cut();
|
|
507
|
+
}, CARRY);
|
|
508
|
+
for (const [{ step }, fold] of folds) {
|
|
509
|
+
step.deletions = Y.mergeDeleteSets([step.deletions, rangesOf(fold.deleted)]);
|
|
510
|
+
step.insertions = Y.mergeDeleteSets([step.insertions, rangesOf(fold.inserted)]);
|
|
511
|
+
}
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const eachUnit = (pd, set, f) => {
|
|
516
|
+
for (const [client, ranges] of set.clients) for (const { clock, len } of ranges) {
|
|
517
|
+
for (let at = clock; at < clock + len;) {
|
|
518
|
+
let it = null;
|
|
519
|
+
try { it = Y.getItem(pd.ydoc.store, { client, clock: at }); } catch { break; }
|
|
520
|
+
const end = Math.min(clock + len, it.id.clock + it.length);
|
|
521
|
+
if (it instanceof Y.Item && it.parent === pd.flow && it.countable) for (let unit = at; unit < end; unit++) f(it, { client, clock: unit });
|
|
522
|
+
at = end;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const recorded = (records, id) => records.some((record) => record.client === id.client && id.clock >= record.clock && id.clock < record.clock + record.length);
|
|
528
|
+
|
|
529
|
+
const gone = (pd, id) => {
|
|
530
|
+
try { return Y.getItem(pd.ydoc.store, id).deleted; } catch { return true; }
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// Before a step is replayed. A word it inserted that a carry or another replay moved (that
|
|
534
|
+
// unit and its source unit are both gone, and no person's record deleted them) is followed to
|
|
535
|
+
// every live unit holding it, so the typist's Undo takes the word out where it now is. A unit
|
|
536
|
+
// the step would re-create is left out when nothing holds its word any more, so a Redo never
|
|
537
|
+
// brings back a word its typist undid or a person deleted.
|
|
538
|
+
const followStep = (pd, origins, step) => {
|
|
539
|
+
const dead = [];
|
|
540
|
+
eachUnit(pd, step.insertions, (it, id) => { if (it.deleted) dead.push(id); });
|
|
541
|
+
if (!dead.length) return;
|
|
542
|
+
const records = parseGoneIntents(pd.gone).textDeletes;
|
|
543
|
+
const words = new Map();
|
|
544
|
+
for (const id of dead) {
|
|
545
|
+
const source = origins.resolve(id);
|
|
546
|
+
const word = words.get(idKey(source)) ?? words.set(idKey(source), { source, follow: false, redo: [] }).get(idKey(source));
|
|
547
|
+
if (!word.follow && !recorded(records, id) && gone(pd, source)) word.follow = true;
|
|
548
|
+
}
|
|
549
|
+
eachUnit(pd, step.deletions, (it, id) => words.get(idKey(origins.resolve(id)))?.redo.push(id));
|
|
550
|
+
const wanted = new Map();
|
|
551
|
+
for (const word of words.values()) {
|
|
552
|
+
if (!word.follow && !word.redo.length) continue;
|
|
553
|
+
word.lives = [];
|
|
554
|
+
(wanted.get(word.source.client) ?? wanted.set(word.source.client, []).get(word.source.client)).push(word);
|
|
555
|
+
}
|
|
556
|
+
if (!wanted.size) return;
|
|
557
|
+
for (let it = pd.flow._start; it; it = it.right) {
|
|
558
|
+
if (it.deleted || !it.countable) continue;
|
|
559
|
+
let at = it.id.clock;
|
|
560
|
+
for (const piece of origins.resolveRange(it.id.client, it.id.clock, it.length)) {
|
|
561
|
+
for (const word of wanted.get(piece.client) ?? []) {
|
|
562
|
+
const offset = word.source.clock - piece.clock;
|
|
563
|
+
if (offset >= 0 && offset < piece.length) word.lives.push({ client: it.id.client, clock: at + offset, len: 1 });
|
|
564
|
+
}
|
|
565
|
+
at += piece.length;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const adds = [];
|
|
569
|
+
for (const list of wanted.values()) for (const word of list) {
|
|
570
|
+
if (word.follow) adds.push(...word.lives);
|
|
571
|
+
if (!word.lives.length) for (const id of word.redo) adds.push({ client: id.client, clock: id.clock, len: 1 });
|
|
572
|
+
}
|
|
573
|
+
if (adds.length) step.insertions = Y.mergeDeleteSets([step.insertions, rangesOf(adds)]);
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const holds = (it, id) => id !== null && it.id.client === id.client && id.clock >= it.id.clock && id.clock < it.id.clock + it.length;
|
|
577
|
+
|
|
578
|
+
// A block marker another tab inserted between a carried word and the unit the carry put it
|
|
579
|
+
// after, without having seen the word (its right neighbour lies past the word). Yjs orders such
|
|
580
|
+
// ties by client number, so the word can end up inside that tab's new block. Returns the marker
|
|
581
|
+
// nearest the unit the carry put the word after.
|
|
582
|
+
const cutIn = (pd, ids) => {
|
|
583
|
+
let item;
|
|
584
|
+
try { item = Y.getItem(pd.ydoc.store, ids[0]); } catch { return null; }
|
|
585
|
+
if (item.id.clock !== ids[0].clock || !item.origin) return null;
|
|
586
|
+
const between = [item];
|
|
587
|
+
let edge = null;
|
|
588
|
+
for (let it = item.left; it && !holds(it, item.origin); it = it.left) {
|
|
589
|
+
if (markerOf(it) && it.id.client !== pd.ydoc.clientID && !between.some((other) => holds(other, it.rightOrigin))) edge = it;
|
|
590
|
+
between.push(it);
|
|
591
|
+
}
|
|
592
|
+
return edge;
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
// The carrying tab alone puts such a word back in front of that marker, into the block it was
|
|
596
|
+
// carried to. If that block is hidden by then, the word is set aside there as any hidden word is.
|
|
597
|
+
const reseat = (pd, origins, walked, placed) => {
|
|
598
|
+
const kept = [], moved = [];
|
|
599
|
+
for (const group of placed) {
|
|
600
|
+
const live = group.ids.filter((id) => !gone(pd, id));
|
|
601
|
+
if (!live.length) continue;
|
|
602
|
+
const edge = cutIn(pd, group.ids);
|
|
603
|
+
if (edge) moved.push({ group, edge, live: new Set(live.map(idKey)) });
|
|
604
|
+
else kept.push(group);
|
|
605
|
+
}
|
|
606
|
+
if (!moved.length) return placed;
|
|
607
|
+
const self = pd.ydoc.clientID;
|
|
608
|
+
pd.ydoc.transact(() => {
|
|
609
|
+
for (const { group, edge, live } of moved) {
|
|
610
|
+
const formats = new Map();
|
|
611
|
+
const units = [];
|
|
612
|
+
let at = 0;
|
|
613
|
+
for (let it = pd.flow._start; it; it = it.right) {
|
|
614
|
+
if (it.content instanceof Y.ContentFormat) {
|
|
615
|
+
if (!it.deleted) { if (it.content.value === null) formats.delete(it.content.key); else formats.set(it.content.key, it.content.value); }
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (it.deleted || !it.countable) continue;
|
|
619
|
+
for (let offset = 0; offset < it.length; offset++) {
|
|
620
|
+
const id = { client: it.id.client, clock: it.id.clock + offset };
|
|
621
|
+
if (live.has(idKey(id))) units.push({ at: at + offset, ch: it.content instanceof Y.ContentString ? it.content.str[offset] : undefined, embed: it.content.embed, attrs: Object.fromEntries(formats), source: origins.resolve(id) });
|
|
622
|
+
}
|
|
623
|
+
at += it.length;
|
|
624
|
+
}
|
|
625
|
+
for (let i = units.length - 1; i >= 0; i--) pd.flow.delete(units[i].at, 1);
|
|
626
|
+
let cursor = 0;
|
|
627
|
+
for (let it = pd.flow._start; it && it !== edge; it = it.right) if (!it.deleted && it.countable) cursor += it.length;
|
|
628
|
+
const start = cursor;
|
|
629
|
+
const before = Y.getState(pd.ydoc.store, self);
|
|
630
|
+
const fences = [...walked.fences, edge.id];
|
|
631
|
+
for (const unit of units) {
|
|
632
|
+
if (unit.ch === undefined) beforeFence(pd, cursor, fences, () => pd.flow.insertEmbed(cursor, { ...unit.embed }, { ...unit.attrs }));
|
|
633
|
+
else beforeFence(pd, cursor, fences, () => pd.flow.insert(cursor, unit.ch, { ...unit.attrs }));
|
|
634
|
+
cursor += 1;
|
|
635
|
+
}
|
|
636
|
+
const dest = unitIds(pd.flow, start, cursor);
|
|
637
|
+
if (dest.length !== units.length) throw new Error(`Moved ${dest.length} carried units instead of ${units.length}`);
|
|
638
|
+
origins.recordSpans(dest.map((id, i) => ({ dest: id, source: units[i].source })));
|
|
639
|
+
if (group.step) group.step.insertions = Y.mergeDeleteSets([group.step.insertions, rangesOf([{ client: self, clock: before, len: Y.getState(pd.ydoc.store, self) - before }])]);
|
|
640
|
+
kept.push({ ids: dest, step: group.step });
|
|
641
|
+
}
|
|
642
|
+
}, CARRY);
|
|
643
|
+
return kept;
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
// Whether Yjs will change anything when it pops this step. It pops every step that would not,
|
|
647
|
+
// in the same press, so each of those needs its moved words followed too.
|
|
648
|
+
const acts = (pd, step) => {
|
|
649
|
+
const store = pd.ydoc.store;
|
|
650
|
+
for (const [client, ranges] of step.insertions.clients) for (const { clock, len } of ranges) {
|
|
651
|
+
for (let at = clock; at < clock + len;) {
|
|
652
|
+
let it;
|
|
653
|
+
try { it = Y.getItem(store, { client, clock: at }); } catch { break; }
|
|
654
|
+
let live = it;
|
|
655
|
+
while (live instanceof Y.Item && live.redone) { try { live = Y.getItem(store, live.redone); } catch { break; } }
|
|
656
|
+
if (live instanceof Y.Item && !live.deleted) return true;
|
|
657
|
+
at = it.id.clock + it.length;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
for (const [client, ranges] of step.deletions.clients) for (const { clock } of ranges) {
|
|
661
|
+
if (!Y.isDeleted(step.insertions, { client, clock })) return true;
|
|
662
|
+
}
|
|
663
|
+
return false;
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const liveIn = (pd, sourceKey) => {
|
|
667
|
+
const live = new Set();
|
|
668
|
+
for (let it = startOf(pd, sourceKey)?.right ?? null; it; it = it.right) {
|
|
669
|
+
if (!it.countable || it.deleted) continue;
|
|
670
|
+
if (markerOf(it)) break;
|
|
671
|
+
for (let offset = 0; offset < it.length; offset++) live.add(idKey({ client: it.id.client, clock: it.id.clock + offset }));
|
|
672
|
+
}
|
|
673
|
+
return live;
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
// Only the tab whose own Undo retired a copy carries that copy's words: a word's home copy is
|
|
677
|
+
// fixed (homeOf), so no two tabs carry the same word. A Redo carries nothing forward and
|
|
678
|
+
// mirrors only the recorded loss of a unit alive at the Undo (`alive`) or newer (`seen`).
|
|
679
|
+
export function createMoveCarry({ pd, origins, um, walked }) {
|
|
680
|
+
const owned = new Map();
|
|
681
|
+
const shown = new Map();
|
|
682
|
+
const homes = new WeakMap();
|
|
683
|
+
let flip = null;
|
|
684
|
+
let placed = [];
|
|
685
|
+
const run = (flipped = null, records = false) => owned.size > 0 && carryAcross({ pd, origins, walked: walked(), placed }, owned, homes, flipped, records);
|
|
686
|
+
return {
|
|
687
|
+
note(t) {
|
|
688
|
+
const keys = [...(t.changed.get(pd.gone) ?? [])].filter((key) => typeof key === "string");
|
|
689
|
+
if (!keys.some((key) => key.startsWith(COPY_ACTIVE_KEYS))) return;
|
|
690
|
+
const hidden = new Set();
|
|
691
|
+
const past = rangesOf([...t.deleteSet.clients].flatMap(([client, ranges]) => ranges.map(({ clock, len }) => ({ client, clock, len }))));
|
|
692
|
+
for (const key of keys) {
|
|
693
|
+
const copy = key.startsWith(COPY_ACTIVE_KEYS);
|
|
694
|
+
if (!copy && !key.startsWith(REMOVE_KEYS)) continue;
|
|
695
|
+
if (pd.gone.has(key) === copy) {
|
|
696
|
+
owned.delete(key);
|
|
697
|
+
if (!copy) shown.set(key, { seen: t.afterState, alive: liveIn(pd, JSON.parse(key)[1]) });
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
hidden.add(key);
|
|
701
|
+
owned.set(key, { copy, left: new Set(), past, seen: null, alive: null, step: null, ...(copy ? null : shown.get(key)) });
|
|
702
|
+
}
|
|
703
|
+
if (hidden.size) flip = hidden;
|
|
704
|
+
},
|
|
705
|
+
replay(direction) {
|
|
706
|
+
const pending = direction === "undo" ? um.undoStack : um.redoStack;
|
|
707
|
+
for (let i = pending.length - 1; i >= 0; i--) {
|
|
708
|
+
followStep(pd, origins, pending[i]);
|
|
709
|
+
if (acts(pd, pending[i])) break;
|
|
710
|
+
}
|
|
711
|
+
const inverse = direction === "undo" ? um.redoStack : um.undoStack;
|
|
712
|
+
const size = inverse.length;
|
|
713
|
+
flip = null;
|
|
714
|
+
if (um[direction]() === null) return false;
|
|
715
|
+
const flipped = flip;
|
|
716
|
+
flip = null;
|
|
717
|
+
if (flipped && inverse.length > size) for (const key of flipped) owned.get(key).step = inverse[inverse.length - 1];
|
|
718
|
+
run(flipped);
|
|
719
|
+
return true;
|
|
720
|
+
},
|
|
721
|
+
received(transactions) {
|
|
722
|
+
if (!owned.size && !placed.length) return;
|
|
723
|
+
let late = false, records = false;
|
|
724
|
+
for (const t of transactions) {
|
|
725
|
+
if (t.local) continue;
|
|
726
|
+
late = true;
|
|
727
|
+
for (const key of t.changed.get(pd.gone) ?? []) if (typeof key === "string" && key.startsWith(TEXT_DELETE_KEYS)) records = true;
|
|
728
|
+
}
|
|
729
|
+
if (!late) return;
|
|
730
|
+
run(null, records);
|
|
731
|
+
if (placed.length) placed = reseat(pd, origins, walked(), placed);
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
@@ -288,7 +288,7 @@ export function unitIds(flow, from, to) {
|
|
|
288
288
|
return out;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
const coversOriginal = (records, self, id) => records.some((record) => {
|
|
291
|
+
export const coversOriginal = (records, self, id) => records.some((record) => {
|
|
292
292
|
if (record.actor === self || record.client !== id.client) return false;
|
|
293
293
|
if (id.clock < record.clock || id.clock >= record.clock + record.length) return false;
|
|
294
294
|
return record.sv.some(([client, clock]) => client === id.client && clock > id.clock);
|
|
@@ -345,7 +345,7 @@ const holdsClock = (spans, clock) => {
|
|
|
345
345
|
return false;
|
|
346
346
|
};
|
|
347
347
|
|
|
348
|
-
const visibleOriginIndex = (flow, walked, index) => {
|
|
348
|
+
export const visibleOriginIndex = (flow, walked, index) => {
|
|
349
349
|
const ranges = createProjectionMap(walked, flow.length).physicalRanges(0, walked.tokens);
|
|
350
350
|
const clocks = new Map();
|
|
351
351
|
const collect = (item, from, to) => {
|
package/src/plugins/page.js
CHANGED
|
@@ -296,10 +296,23 @@ function listen(inst, type, handler) {
|
|
|
296
296
|
inst.listeners.push([type, handler]);
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
+
// On a page that loads `undo` there is one history and it is the page's: every route the
|
|
300
|
+
// editor offers walks it, and the editor's own stack only says where its steps sit on it.
|
|
301
|
+
const paged = () => window.clay?.undo?.pageHistory === true;
|
|
302
|
+
|
|
299
303
|
// The page's own history, the one the editor's Cmd+Z already uses: nothing is undoable
|
|
300
304
|
// while a proposal is only a preview, because Discard is the whole of that change and
|
|
301
305
|
// Keep is the one step worth undoing.
|
|
302
306
|
function pageHistory(inst) {
|
|
307
|
+
if (paged()) {
|
|
308
|
+
const history = window.clay.undo;
|
|
309
|
+
return {
|
|
310
|
+
undo: () => history.undo(),
|
|
311
|
+
redo: () => history.redo(),
|
|
312
|
+
get canUndo() { return !!history.canUndo; },
|
|
313
|
+
get canRedo() { return !!history.canRedo; },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
303
316
|
const binding = inst?.binding;
|
|
304
317
|
const um = binding?.bridge?.um;
|
|
305
318
|
if (!binding || !um) return null;
|
|
@@ -327,6 +340,9 @@ function pageHistory(inst) {
|
|
|
327
340
|
// One event for whatever presents the history, the same one plugins/undo.js emits: after
|
|
328
341
|
// every entry the page's stack takes or replays, and whenever a preview comes up or goes.
|
|
329
342
|
function notifyHistory(inst) {
|
|
343
|
+
// The undo plugin announces the one page history itself; a second event from here would
|
|
344
|
+
// say the editor's own stack decides what is undoable.
|
|
345
|
+
if (paged()) return;
|
|
330
346
|
const history = pageHistory(inst);
|
|
331
347
|
document.dispatchEvent(new CustomEvent("clay:undo-change", {
|
|
332
348
|
detail: { canUndo: !!history?.canUndo, canRedo: !!history?.canRedo },
|
|
@@ -985,7 +1001,9 @@ export function createSteps(inst, options) {
|
|
|
985
1001
|
install: async (id, mod) => {
|
|
986
1002
|
if (!editorView) await editorReady;
|
|
987
1003
|
if (!isCurrent(inst) || !editorView) return null;
|
|
988
|
-
const
|
|
1004
|
+
const undo = () => (paged() ? window.clay.undo.undo() : binding.undo());
|
|
1005
|
+
const redo = () => (paged() ? window.clay.undo.redo() : binding.redo());
|
|
1006
|
+
const context = { view: editorView, upload: isDraftMode ? draftUpload : options.upload, board: binding.board, undo, redo, revise: aiRequested() ? (toolId) => { openAi({ target: { toolId } }); } : null };
|
|
989
1007
|
if (id === "tool") {
|
|
990
1008
|
context.toolHost = await openTools();
|
|
991
1009
|
if (!isCurrent(inst)) return null;
|
|
@@ -1200,8 +1218,8 @@ function mountAndAttach(inst, source, loaded) {
|
|
|
1200
1218
|
doc: binding.doc(),
|
|
1201
1219
|
dispatch: binding.dispatch,
|
|
1202
1220
|
history: false,
|
|
1203
|
-
undo: binding.undo,
|
|
1204
|
-
redo: binding.redo,
|
|
1221
|
+
undo: () => (paged() ? window.clay.undo.undo() : binding.undo()),
|
|
1222
|
+
redo: () => (paged() ? window.clay.undo.redo() : binding.redo()),
|
|
1205
1223
|
board: binding.board,
|
|
1206
1224
|
editable: false,
|
|
1207
1225
|
features,
|
|
@@ -1263,10 +1281,23 @@ function mountAndAttach(inst, source, loaded) {
|
|
|
1263
1281
|
}
|
|
1264
1282
|
loaded.refreshView?.(view);
|
|
1265
1283
|
binding.attach(view);
|
|
1284
|
+
// Every mounted editor hands its own steps to the page's history through this adapter,
|
|
1285
|
+
// made here whether or not a session is live yet: the adapter reads the history per
|
|
1286
|
+
// step and follows the session as it starts, stops and starts again.
|
|
1287
|
+
const um = binding.bridge?.um;
|
|
1288
|
+
if (um) {
|
|
1289
|
+
const bridge = pageCode.createHistoryBridge({
|
|
1290
|
+
um,
|
|
1291
|
+
undo: () => binding.undo(),
|
|
1292
|
+
redo: () => binding.redo(),
|
|
1293
|
+
article,
|
|
1294
|
+
focus: () => inst.view?.focus?.(),
|
|
1295
|
+
});
|
|
1296
|
+
addDisposer(inst, bridge.dispose);
|
|
1297
|
+
}
|
|
1266
1298
|
// The page's history tells whoever presents it once it exists (a menu opened before the
|
|
1267
1299
|
// editor came up gets its Undo row then), and once per entry it takes or replays. A
|
|
1268
1300
|
// peer's typing takes none of them: their work is not this tab's to undo.
|
|
1269
|
-
const um = binding.bridge?.um;
|
|
1270
1301
|
if (um) {
|
|
1271
1302
|
const notify = () => notifyHistory(inst);
|
|
1272
1303
|
um.on("stack-item-added", notify);
|
|
@@ -1287,9 +1318,22 @@ function mountAndAttach(inst, source, loaded) {
|
|
|
1287
1318
|
const previewIs = previewShown(next.state);
|
|
1288
1319
|
if (previewIs === previewWas) return;
|
|
1289
1320
|
previewWas = previewIs;
|
|
1321
|
+
// A proposal on screen owns the moment: the page's history is held, so Cmd+Z can
|
|
1322
|
+
// undo nothing behind it, and released when the proposal goes.
|
|
1323
|
+
if (paged()) {
|
|
1324
|
+
if (previewIs) inst.releaseHold = window.clay.undo.hold();
|
|
1325
|
+
else {
|
|
1326
|
+
inst.releaseHold?.();
|
|
1327
|
+
inst.releaseHold = null;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1290
1330
|
notifyHistory(inst);
|
|
1291
1331
|
},
|
|
1292
1332
|
}));
|
|
1333
|
+
addDisposer(inst, () => {
|
|
1334
|
+
inst.releaseHold?.();
|
|
1335
|
+
inst.releaseHold = null;
|
|
1336
|
+
});
|
|
1293
1337
|
installAwareness(inst, view);
|
|
1294
1338
|
editorReadyResolve();
|
|
1295
1339
|
// The parts the saved article demands install onto the mounted view now, whether
|