feat: F5.6d a drag is one change of the document; the others see it through the presence

Moving a device, moving or resizing a documentation item went into the
shared document at every pointer move: 59 updates a second, each leaving
about 18 bytes of history (M4 on test-filius). Now the editor keeps the drag
while it lasts, shows it itself and tells the others through the awareness
(moving, at most every 50 ms, the end at once), and writes it once when it
is let go. A drag the browser cancels or a second finger turns into a pinch
is written where it is; WebKit sends a second pointerdown for the first
finger when another comes, which had lost the drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 07:17:38 +00:00
co-authored by Claude Opus 5.5
parent 8991df8b83
commit 44b3f3ccb2
16 changed files with 418 additions and 45 deletions
+66
View File
@@ -0,0 +1,66 @@
// A drag (moving.ts, M4): shown over the document while it lasts — a device
// at its dragged place, an item in the frame the document will hold — and
// written into the document once, as one change, when it is let go.
import { randomUUID } from 'node:crypto';
import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { addDevice, addDocumentation, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
import { commitMove, movedView } from '../src/moving.js';
function project() {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
addDevice(doc, { kind: 'pc', id: 'pc', name: 'Rechner', x: 100, y: 100, newId: randomUUID }, 'me');
addDevice(doc, { kind: 'notebook', id: 'nb', name: 'Notebook', x: 300, y: 100, newId: randomUUID }, 'me');
addDocumentation(doc, { id: 'box', kind: 'rectangle', x: 50, y: 50 }, 'me');
return doc;
}
describe('a drag over the document', () => {
it('is the view itself without a drag', () => {
const view = topologyView(project());
expect(movedView(view, [])).toBe(view);
});
it('shows a dragged device where it is dragged and leaves the others where they are', () => {
const view = movedView(topologyView(project()), [{ kind: 'device', id: 'pc', x: 140.5, y: 260 }]);
expect(view.nodes.map((node) => [node.id, node.x, node.y])).toEqual([
['pc', 140.5, 260],
['nb', 300, 100],
]);
});
it('shows the later of two drags of one device (this editor puts its own last)', () => {
const view = movedView(topologyView(project()), [
{ kind: 'device', id: 'pc', x: 1, y: 1 },
{ kind: 'device', id: 'pc', x: 2, y: 2 },
]);
expect(view.nodes.find((node) => node.id === 'pc')).toMatchObject({ x: 2, y: 2 });
});
it('shows a dragged item in the frame the document will hold, and passes over what it does not know', () => {
const doc = project();
const before = topologyView(doc);
const view = movedView(before, [
{ kind: 'doc', id: 'box', x: 200, y: 120, width: -80, height: 40 },
{ kind: 'device', id: 'gone', x: 1, y: 1 },
]);
const box = view.docs.find((item) => item.id === 'box')!;
expect([box.x, box.y, box.width, box.height]).toEqual([120, 120, 80, 40]);
expect(view.nodes).toEqual(before.nodes);
commitMove(doc, { kind: 'doc', id: 'box', x: 200, y: 120, width: -80, height: 40 }, 'me');
const stored = topologyView(doc).docs.find((item) => item.id === 'box')!;
expect([stored.x, stored.y, stored.width, stored.height]).toEqual([box.x, box.y, box.width, box.height]);
});
it('writes a drag into the document as one change, rounded as Java holds places', () => {
const doc = project();
let changes = 0;
doc.on('update', () => (changes += 1));
commitMove(doc, { kind: 'device', id: 'pc', x: 140.6, y: 259.4 }, 'me');
expect(changes).toBe(1);
const pc = topologyView(doc).nodes.find((node) => node.id === 'pc')!;
expect([pc.x, pc.y]).toEqual([141, 259]);
});
});
+94 -13
View File
@@ -1,15 +1,23 @@
// The others in the project (plan §7.4): another editor's state read with
// care, the others by name without this editor, each person once however
// many tabs they have, their choices as frames and their windows as dots,
// a colour for whoever the dashboard gave none, initials; and this editor's
// state told only when it changed — nothing that changes without a cause.
// a colour for whoever the dashboard gave none, initials, their drags; and
// this editor's state told only when it changed, a drag at most every 50 ms —
// nothing that changes without a cause.
import { describe, expect, it } from 'vitest';
import { initialsOf } from '../src/remote/PresenceMarks.js';
import { colorOf, peersOf, peopleOf, PRESENCE_COLORS, readPresence, selectionsOf, watchersOf, windowKey, type PresenceState } from '../src/remote/presence.js';
import { presencePublisher } from '../src/remote/usePresence.js';
import type { Moving } from '../src/moving.js';
import { colorOf, movesOf, peersOf, peopleOf, PRESENCE_COLORS, readPresence, selectionsOf, watchersOf, windowKey, type PresenceState } from '../src/remote/presence.js';
import { MOVING_MILLISECONDS, presencePublisher, type PublisherClock } from '../src/remote/usePresence.js';
const state = (id: string, name: string, selection: string | null = null, windows: string[] = [], color = '#112233'): PresenceState => ({ v: 1, user: { id, name, color }, selection, windows });
const state = (id: string, name: string, selection: string | null = null, windows: string[] = [], color = '#112233', moving: Moving | null = null): PresenceState => ({
v: 1,
user: { id, name, color },
selection,
windows,
moving,
});
describe('the others in the project', () => {
it('reads a state of the shape it writes, and leaves out any other', () => {
@@ -31,6 +39,34 @@ describe('the others in the project', () => {
expect(readPresence(bad)).toBeUndefined();
});
it("reads another editor's drag, none from an editor that does not tell drags, and leaves out a drag of another shape", () => {
const device: Moving = { kind: 'device', id: 'pc', x: 140.5, y: -20 };
const item: Moving = { kind: 'doc', id: 'box', x: 1, y: 2, width: 30, height: 40 };
expect(readPresence(state('u', 'Mia', null, [], '#112233', device))?.moving).toEqual(device);
expect(readPresence(state('u', 'Mia', null, [], '#112233', item))?.moving).toEqual(item);
const { moving: _left, ...older } = state('u', 'Mia');
expect(readPresence(older)?.moving).toBeNull();
for (const bad of [
'pc',
{ kind: 'rotate', id: 'pc', x: 1, y: 1 },
{ kind: 'device', id: '', x: 1, y: 1 },
{ kind: 'device', id: 7, x: 1, y: 1 },
{ kind: 'device', id: 'pc', x: Number.NaN, y: 1 },
{ kind: 'device', id: 'pc', x: 1, y: Number.POSITIVE_INFINITY },
{ kind: 'device', id: 'pc', x: 1_000_001, y: 1 },
{ kind: 'device', id: 'pc', x: '1', y: 1 },
{ kind: 'doc', id: 'box', x: 1, y: 2, width: 30 },
{ kind: 'doc', id: 'box', x: 1, y: 2, width: 30, height: -2_000_000 },
])
expect(readPresence({ ...state('u', 'Mia'), moving: bad })).toBeUndefined();
});
it("gathers the others' drags", () => {
const device: Moving = { kind: 'device', id: 'pc', x: 1, y: 2 };
const peers = peersOf(new Map<number, unknown>([[2, state('b', 'Ben', null, [], '#112233', device)], [3, state('a', 'Ada')]]), 1);
expect(movesOf(peers)).toEqual([device]);
});
it('lists the other editors by name, without this one and without what it cannot read', () => {
const states = new Map<number, unknown>([
[1, state('me', 'Zoe')],
@@ -112,15 +148,60 @@ describe('the others in the project', () => {
it("tells this editor's state only when it changed", () => {
const set: unknown[] = [];
const publish = presencePublisher({ setLocalState: (value) => void set.push(value) }, { id: 'me', name: 'Zoe', color: '' });
publish(null, []);
publish(null, []);
publish('pc', []);
publish('pc', []);
publish('pc', ['desktop:pc']);
publish(null, [], null);
publish(null, [], null);
publish('pc', [], null);
publish('pc', [], null);
publish('pc', ['desktop:pc'], null);
expect(set).toEqual([
{ v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: null, windows: [] },
{ v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: [] },
{ v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: ['desktop:pc'] },
{ v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: null, windows: [], moving: null },
{ v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: [], moving: null },
{ v: 1, user: { id: 'me', name: 'Zoe', color: '' }, selection: 'pc', windows: ['desktop:pc'], moving: null },
]);
});
it('tells a drag at most every 50 ms, its latest place, and its end at once', () => {
let time = 0;
const timers: { at: number; run: () => void; cancelled: boolean }[] = [];
const clock: PublisherClock = {
now: () => time,
later: (run, milliseconds) => {
const timer = { at: time + milliseconds, run, cancelled: false };
timers.push(timer);
return timer;
},
cancel: (timer) => void ((timer as { cancelled: boolean }).cancelled = true),
};
const until = (at: number) => {
time = at;
for (const timer of timers) if (!timer.cancelled && timer.at <= at && timers.includes(timer)) timers.splice(timers.indexOf(timer), 1), timer.run();
};
const told: unknown[] = [];
const publish = presencePublisher({ setLocalState: (value) => void told.push((value as PresenceState).moving) }, { id: 'me', name: 'Zoe', color: '' }, clock);
const at = (x: number): Moving => ({ kind: 'device', id: 'pc', x, y: 0 });
publish('pc', [], null);
until(10);
publish('pc', [], at(1));
until(20);
publish('pc', [], at(2));
expect(told).toEqual([null]);
until(MOVING_MILLISECONDS);
expect(told).toEqual([null, at(2)]);
until(80);
publish('pc', [], at(3));
until(90);
publish('pc', [], at(4));
// Back to what was told last: nothing is left waiting.
publish('pc', [], at(2));
until(200);
expect(told).toEqual([null, at(2)]);
publish('pc', [], at(5));
expect(told).toEqual([null, at(2), at(5)]);
until(210);
publish('pc', [], at(6));
until(220);
publish('pc', [], null);
until(400);
expect(told).toEqual([null, at(2), at(5), null]);
});
});