Files
julian.kiedaischandClaude Opus 5.5 44b3f3ccb2 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>
2026-10-10 07:17:38 +00:00

208 lines
9.0 KiB
TypeScript

// 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, 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 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', 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', () => {
expect(readPresence(state('u', 'Mia', 'd', ['desktop:d']))).toEqual(state('u', 'Mia', 'd', ['desktop:d']));
for (const bad of [
null,
'Mia',
{ ...state('u', 'Mia'), v: 2 },
{ ...state('u', 'Mia'), user: null },
{ ...state('', 'Mia') },
{ ...state('u', 7 as unknown as string) },
{ ...state('u', 'x'.repeat(201)) },
{ ...state('u', 'Mia'), user: { id: 'u', name: 'Mia', color: 'c'.repeat(33) } },
{ ...state('u', 'Mia'), selection: 5 },
{ ...state('u', 'Mia'), windows: 'desktop:d' },
{ ...state('u', 'Mia'), windows: [7] },
{ ...state('u', 'Mia'), windows: Array.from({ length: 101 }, () => 'w') },
])
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')],
[2, state('b', 'Ben')],
[3, { cursor: { x: 1 } }],
[4, state('a', 'Ada')],
[5, state('b', 'Ben')],
]);
expect(peersOf(states, 1).map((peer) => [peer.clientId, peer.state.user.name])).toEqual([
[4, 'Ada'],
[2, 'Ben'],
[5, 'Ben'],
]);
});
it('counts a person with two tabs once, and leaves out this person in another tab', () => {
const peers = peersOf(
new Map<number, unknown>([
[2, state('b', 'Ben')],
[3, state('b', 'Ben')],
[4, state('me', 'Zoe')],
]),
1,
);
expect(peopleOf(peers, 'me')).toEqual([{ id: 'b', name: 'Ben', color: '#112233' }]);
expect(peopleOf(peers).map((user) => user.id)).toEqual(['b', 'me']);
});
it('gives each chosen device the colours of who chose it, each person once', () => {
const peers = peersOf(
new Map<number, unknown>([
[2, state('b', 'Ben', 'router', [], '#aa0000')],
[3, state('b', 'Ben', 'router', [], '#aa0000')],
[4, state('a', 'Ada', 'router', [], '#00aa00')],
[5, state('c', 'Cem', 'pc', [], '#0000aa')],
[6, state('d', 'Dan', null)],
]),
1,
);
expect(selectionsOf(peers)).toEqual(
new Map([
['router', ['#00aa00', '#aa0000']],
['pc', ['#0000aa']],
]),
);
});
it('gives each open window who has it open, each person once', () => {
const desktop = windowKey('desktop', 'pc');
const peers = peersOf(
new Map<number, unknown>([
[2, state('b', 'Ben', null, [desktop, 'capture'])],
[3, state('b', 'Ben', null, [desktop])],
[4, state('a', 'Ada', null, [windowKey('sat', 's')])],
]),
1,
);
const watchers = watchersOf(peers);
expect([...watchers.keys()].sort()).toEqual(['capture', 'desktop:pc', 'sat:s']);
expect(watchers.get(desktop)!.map((user) => user.id)).toEqual(['b']);
expect([windowKey('nat', 'g'), windowKey('capture', 'ignored')]).toEqual(['nat:g', 'capture']);
});
it("takes the dashboard's colour, and gives one by the id to whoever has none", () => {
expect(colorOf({ id: 'x', name: 'X', color: '#ABCDEF' })).toBe('#ABCDEF');
const given = colorOf({ id: 'person-1', name: 'X', color: '' });
expect(PRESENCE_COLORS).toContain(given);
expect(colorOf({ id: 'person-1', name: 'Y', color: 'blue' })).toBe(given);
// Every editor gives the same colour, whatever its version: pinned.
expect([colorOf({ id: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', name: '', color: '' }), colorOf({ id: 'person-2', name: '', color: '' })]).toEqual(['#e67700', '#1971c2']);
const all = new Set(Array.from({ length: 40 }, (_, index) => colorOf({ id: `p${index}`, name: '', color: '' })));
expect(all.size).toBeGreaterThan(4);
});
it('writes the initials of the first and the last word', () => {
expect([initialsOf('Ada Beispiel'), initialsOf('mia'), initialsOf('Jörg Ömer Weiß'), initialsOf(' '), initialsOf('🐧 Pinguin')]).toEqual(['AB', 'M', 'JW', '?', '🐧P']);
});
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, [], 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: [], 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]);
});
});