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
@@ -11,7 +11,7 @@
import { readFileSync } from 'node:fs';
import { expect, test, type Page } from '@playwright/test';
import { JAVA_LABEL_FONT } from '../src/canvas/layout.js';
import { drag, drawing, openEditor, serveEditor } from './editorPage.js';
import { documentUpdates, drag, drawing, openEditor, serveEditor } from './editorPage.js';
let server: { readonly base: string; readonly close: () => Promise<void> };
@@ -69,9 +69,11 @@ test('makes, moves, resizes, colours and types documentation as Java does, and p
// Java's paint in the documentation mode: grey at an opacity of 200.
expect(await fill(page, rectangle.id)).toBe('rgba(204, 204, 204, 0.784)');
// Moved by its middle, resized by its corner to Java's smallest (20 × 20).
// Moved by its middle, resized by its corner to Java's smallest (20 × 20); a drag is one change of the document, when it is let go.
const changes = await documentUpdates(page);
await drag(page, drop, { x: drop.x + 40, y: drop.y + 30 });
await expect.poll(async () => (await docOf(page, rectangle.id))!.x - rectangle.x).toBeGreaterThan(20);
expect(await documentUpdates(page)).toBe(changes + 1);
const moved = (await docOf(page, rectangle.id))!;
const corner = (await page.locator(`g[data-doc="${rectangle.id}"] .doc-focus`).boundingBox())!;
await drag(page, { x: corner.x + corner.width - 2, y: corner.y + corner.height - 2 }, { x: corner.x - 50, y: corner.y - 50 });
+5
View File
@@ -35,6 +35,11 @@ export function drawing(page: Page): Promise<TopologyView> {
return page.evaluate(() => (window as unknown as { filiusDev: { view: () => TopologyView } }).filiusDev.view());
}
/** How many changes the document has had since the page opened it. */
export function documentUpdates(page: Page): Promise<number> {
return page.evaluate(() => (window as unknown as { filiusDev: { updates: () => number } }).filiusDev.updates());
}
/** What the device sheet reads of a device. */
export function sheetOf(page: Page, id: string): Promise<DeviceSheet | undefined> {
return page.evaluate((each) => (window as unknown as { filiusDev: { sheet: (id: string) => DeviceSheet | undefined } }).filiusDev.sheet(each), id);
+20 -3
View File
@@ -14,7 +14,7 @@ export interface Point {
readonly y: number;
}
type TouchType = 'touchStart' | 'touchMove' | 'touchEnd';
type TouchType = 'touchStart' | 'touchMove' | 'touchEnd' | 'touchCancel';
export interface Fingers {
tap(at: Point): Promise<void>;
@@ -24,6 +24,10 @@ export interface Fingers {
drag(from: Point, to: Point, steps?: number): Promise<void>;
/** Two fingers either side of a middle, moved from one distance apart to another. */
pinch(middle: Point, from: number, to: number, steps?: number): Promise<void>;
/** One finger moved from one point to another, then a second one put down beside it and both moved apart. */
dragThenPinch(from: Point, to: Point): Promise<void>;
/** One finger moved from one point to another, then the touch cancelled (the browser took it). */
dragThenCancel(from: Point, to: Point): Promise<void>;
}
interface WebKitSession {
@@ -47,8 +51,8 @@ export async function fingers(page: Page): Promise<Fingers> {
// WebKit ends a touch with the fingers lifted, not with none.
let last: readonly Point[] = [];
send = (type, points) => {
const touched = type === 'touchEnd' ? last : points;
if (type !== 'touchEnd') last = points;
const touched = type === 'touchEnd' || type === 'touchCancel' ? last : points;
if (type !== 'touchEnd' && type !== 'touchCancel') last = points;
return session.send('Input.dispatchTouchEvent', { type, touchPoints: touched.map(({ x, y }, id) => ({ x: Math.round(x), y: Math.round(y), id })) });
};
} else {
@@ -81,5 +85,18 @@ export async function fingers(page: Page): Promise<Fingers> {
await page.waitForTimeout(150);
await send('touchEnd', []);
},
async dragThenPinch(from, to) {
await send('touchStart', [from]);
for (const share of steps(8)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
await send('touchStart', [to, { x: to.x + 60, y: to.y }]);
for (const share of steps(4)) await send('touchMove', [{ x: to.x - 20 * share, y: to.y }, { x: to.x + 60 + 20 * share, y: to.y }]);
await page.waitForTimeout(150);
await send('touchEnd', []);
},
async dragThenCancel(from, to) {
await send('touchStart', [from]);
for (const share of steps(8)) await send('touchMove', [{ x: from.x + (to.x - from.x) * share, y: from.y + (to.y - from.y) * share }]);
await send('touchCancel', []);
},
};
}
+6
View File
@@ -33,6 +33,8 @@ export interface ServerF {
service(method: string, path: string, body?: unknown): Promise<Response>;
/** The size of a blob in F's store, none without it. */
blobSize(sha: string): Promise<number | undefined>;
/** How many updates of a project F has stored since it last compacted it. */
updateCount(project: string): Promise<number>;
close(): Promise<void>;
}
@@ -97,6 +99,10 @@ export async function startServerF(editorBase: string, editorFolder?: string): P
const result = await sql.query<{ size: number }>('SELECT size FROM filius_blob WHERE sha = $1', [sha]);
return result.rows[0]?.size;
},
async updateCount(project) {
const result = await sql.query<{ count: string }>('SELECT count(*) FROM filius_doc_update WHERE project_uuid = $1', [project]);
return Number(result.rows[0]!.count);
},
service: (method, path, body) => fetch(`${base}${path}`, { method, headers: { 'x-filius-service-key': SERVICE_KEY }, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }),
async close() {
const ended = new Promise((resolve) => child.once('exit', resolve));
+26
View File
@@ -163,6 +163,32 @@ test('three people in one project of F: moving, reading only, one simulation for
await expect(canvas(mia)).toBeVisible();
});
test('shows a drag to the others while it lasts, and stores it in F once, when it is let go', async ({ browser }) => {
const project = await f.project('java-examples/internetwork_komplett.fls');
const mia = await person(browser, project.id, 'Mia');
const ben = await person(browser, project.id, 'Ben');
const id = project.node('Rechner 3.10');
const before = await box(ben, id);
await expect.poll(() => f.updateCount(project.id)).toBe(0);
const from = await onDevice(mia, id);
await mia.mouse.move(from.x, from.y);
await mia.mouse.down();
for (let step = 1; step <= 30; step++) {
await mia.mouse.move(from.x - 3 * step, from.y + 2 * step);
await mia.waitForTimeout(16);
}
// Still held: Mia and Ben see it on its way, F has stored nothing of it.
expect(await box(mia, id)).not.toBe(before);
await expect.poll(async () => (await box(ben, id)) !== before).toBe(true);
expect(await f.updateCount(project.id)).toBe(0);
await mia.mouse.up();
await expect.poll(async () => box(ben, id)).toBe(await box(mia, id));
await expect.poll(() => f.updateCount(project.id)).toBe(1);
await mia.waitForTimeout(500);
expect(await f.updateCount(project.id)).toBe(1);
expect(await box(ben, id)).toBe(await box(mia, id));
});
test('takes a large picture one person imports to F as a blob, and shows it to another', async ({ browser }) => {
test.setTimeout(120_000);
const project = await f.project('java-examples/internetwork_komplett.fls');
+31
View File
@@ -138,6 +138,37 @@ test('zooms the surface with two fingers and never the page (M7)', async ({ page
expect(await page_()).toEqual({ scale: 1, x: 0, y: 0 });
});
test('puts a device or an item down where it is when a second finger comes or the browser takes the touch', async ({ page }) => {
for (const gesture of ['dragThenPinch', 'dragThenCancel'] as const) {
// Each on a fresh page: the pinch zooms, and the next device would be elsewhere.
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const finger = await fingers(page);
const id = (await drawing(page)).nodes[0]!.id;
const before = (await drawing(page)).nodes.find((node) => node.id === id)!;
const from = await onDevice(page, id);
await finger[gesture](from, { x: from.x + 40, y: from.y + 60 });
// In the document, not only on the screen: the drag ended there and then.
await expect
.poll(async () => {
const now = (await drawing(page)).nodes.find((node) => node.id === id)!;
return now.x !== before.x || now.y !== before.y;
}, { message: gesture })
.toBe(true);
const shown = await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box');
await page.waitForTimeout(300);
expect(await page.locator(`g.device[data-node="${id}"]`).getAttribute('data-box')).toBe(shown);
}
// A rectangle of the documentation, its drag taken by the browser: it too stays where it was dragged.
await openEditor(page, server.base, 'nat_with_mapped_port.fls');
const finger = await fingers(page);
await page.locator('button[name="documentationMode"]').tap();
const rectangle = (await drawing(page)).docs.find((item) => item.kind === 'rectangle')!;
const area = (await page.locator(`g[data-doc="${rectangle.id}"]`).boundingBox())!;
const middle = { x: area.x + area.width / 2, y: area.y + area.height / 2 };
await finger.dragThenCancel(middle, { x: middle.x + 40, y: middle.y + 30 });
await expect.poll(async () => (await drawing(page)).docs.find((item) => item.id === rectangle.id)!.x).not.toBe(rectangle.x);
});
test('reaches every action without a right click: a long press, the three-dot button, the documentation', async ({ page }) => {
await openEditor(page, server.base, 'zwei_rechnernetze_komplett.fls');
const finger = await fingers(page);
+31 -12
View File
@@ -22,8 +22,10 @@
//
// On F the others are shown (plan §7.4, presence.ts): a frame in their
// colour around a device they have chosen, a dot on a window they have open
// too, a circle each beside the mode buttons; this editor tells them its own
// choice and windows, and nothing more.
// too, a circle each beside the mode buttons, a device or an item they drag
// where it is now; this editor tells them its own choice, windows and drag,
// and nothing more. A drag goes into the document once, when it is let go
// (moving.ts).
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type * as Y from 'yjs';
@@ -34,7 +36,6 @@ import {
connect,
createUndoManager,
documentationText,
moveDevice,
removeDevice,
removeDocumentation,
removeLink,
@@ -43,7 +44,6 @@ import {
reportDevices,
setDocumentationColor,
setDocumentationFont,
setDocumentationFrame,
topologyView,
type TopologyView,
} from '@mdg-filius/doc';
@@ -69,7 +69,8 @@ import { DropControl } from './simulation/DropControl.js';
import { ViewerWindow, type ViewerKind } from './simulation/ViewerWindow.js';
import { useSimulation, type SimHostFactory } from './simulation/useSimulation.js';
import { PeopleRow } from './remote/PresenceMarks.js';
import { peopleOf, selectionsOf, watchersOf, windowKey } from './remote/presence.js';
import { commitMove, movedView, type Moving } from './moving.js';
import { movesOf, peopleOf, selectionsOf, watchersOf, windowKey } from './remote/presence.js';
import type { Presence } from './remote/usePresence.js';
/** The document's drawing, read anew after every change. */
@@ -186,6 +187,19 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
const [message, setMessage] = useState<string | null>(null);
// The cable of the card whose tab is open in the sheet (Java's Kabel.setAktiv).
const [highlightedLink, setHighlightedLink] = useState<string | null>(null);
// This editor's drag: shown and told while it lasts, written when it ends (the ref for the end, which may come before a render).
const [moving, setMovingState] = useState<Moving | null>(null);
const movingRef = useRef<Moving | null>(null);
const setMoving = useCallback((next: Moving | null) => {
movingRef.current = next;
setMovingState(next);
}, []);
const endMove = useCallback(() => {
const move = movingRef.current;
if (move !== null) commitMove(doc, move, origin);
setMoving(null);
manager?.stopCapturing();
}, [doc, origin, manager, setMoving]);
// The room said no: told as a message, once each time.
useEffect(() => {
@@ -376,13 +390,18 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
// The others: told this editor's chosen device and open windows (a window is open only while the simulation runs).
const windows = simulating ? desktops.map((desktop) => (desktop.viewer === 'capture' ? windowKey('capture') : windowKey(desktop.viewer ?? 'desktop', desktop.id))) : [];
const told = JSON.stringify([selected?.id ?? null, windows]);
const told = JSON.stringify([selected?.id ?? null, windows, moving]);
const publish = presence?.publish;
useEffect(() => {
const [device, open] = JSON.parse(told) as [string | null, string[]];
publish?.(device, open);
const [device, open, drag] = JSON.parse(told) as [string | null, string[], Moving | null];
publish?.(device, open, drag);
}, [told, publish]);
const peers = presence?.peers;
// What the surface shows: the document, with the others' drags and this editor's own (the last of a thing wins: one's own).
const shown = useMemo(() => {
const theirs = movesOf(peers ?? []);
return movedView(view, moving === null ? theirs : [...theirs, moving]);
}, [view, peers, moving]);
const chosenBy = useMemo(() => selectionsOf(peers ?? []), [peers]);
const watchers = useMemo(() => watchersOf(peers ?? []), [peers]);
const people = useMemo(() => peopleOf(peers ?? [], presence?.you.id), [peers, presence?.you.id]);
@@ -429,20 +448,20 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz
<div className="editor-area">
<Canvas
ref={canvas}
view={view}
view={shown}
tool={cableTool ? 'cable' : 'select'}
selection={selection}
cableFrom={cableFrom}
onSelect={setSelection}
onMoveDevice={(id, x, y) => moveDevice(doc, id, x, y, origin)}
onMoveEnd={() => manager?.stopCapturing()}
onMoveDevice={(id, x, y) => setMoving({ kind: 'device', id, x, y })}
onMoveEnd={endMove}
onCableStart={setCableFrom}
onCable={onCable}
highlightedLink={highlightedLink}
mode={editorMode}
locked={readOnly}
chosenBy={chosenBy}
onDocFrame={(id, frame) => setDocumentationFrame(doc, id, frame, origin)}
onDocFrame={(id, frame) => setMoving({ kind: 'doc', id, ...frame })}
renderDocText={(item, frame) => {
const text = documentationText(doc, item.id);
return text === undefined ? null : (
+9
View File
@@ -69,6 +69,7 @@ export interface CanvasProps {
readonly onSelect?: (selection: Selection | null) => void;
/** A device dragged: its button's new top left corner. */
readonly onMoveDevice?: (id: string, x: number, y: number) => void;
/** A drag of a device or an item over: let go, taken by the browser, or turned into a pinch by a second finger. */
readonly onMoveEnd?: () => void;
readonly onCableStart?: (from: { readonly id: string; readonly side: Side } | null) => void;
readonly onCable?: (from: { readonly id: string; readonly side: Side }, to: { readonly id: string; readonly side: Side }) => void;
@@ -392,9 +393,15 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (surface.current !== null && !surface.current.hasPointerCapture(pointerId)) surface.current.setPointerCapture(pointerId);
};
/** Whether a gesture was on a device or an item: its end lets the editor write what it was dragging, if anything (a tap, a long press, the cable tool drag nothing). */
const draggedSomething = (now: Gesture | null) => now?.kind === 'device' || now?.kind === 'doc';
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point);
// A press that takes the place of a drag ends it where it is: a second finger (a pinch), or WebKit's
// second pointerdown for a finger that is already down, which it sends when another one comes.
if (draggedSomething(gesture.current)) onMoveEnd?.();
if (pointers.current.size === 2) {
// A second finger: a pinch, whatever the first one did.
clearLongPress();
@@ -524,6 +531,8 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
gesture.current = remaining === undefined ? null : { kind: 'background', start: remaining, moved: true, last: remaining };
if (now === null || now.kind === 'pinch' || event.type === 'pointercancel') {
setBand(null);
// The browser took the pointer: a drag ends where it is, as it would have stood in the document.
if (draggedSomething(now)) onMoveEnd?.();
return;
}
if (now.kind === 'doc') {
+6 -1
View File
@@ -72,18 +72,23 @@ function App() {
});
}, [example]);
// The development page lets its browser tests read the document's drawing and write into a file as another would (never the editor embedded in the dashboard).
// The development page lets its browser tests read the document's drawing, count its changes and write into a file as another would (never the editor embedded in the dashboard).
useEffect(() => {
let updates = 0;
const counted = () => (updates += 1);
doc?.on('update', counted);
(window as unknown as { filiusDev?: unknown }).filiusDev =
doc === null
? undefined
: {
view: () => topologyView(doc),
updates: () => updates,
sheet: (id: string) => deviceSheet(doc, id),
desktop: (id: string) => desktopView(doc, id),
insertText: (id: string, path: string, index: number, text: string) => doc.transact(() => fileText(doc, id, path)?.insert(index, text), 'other'),
writeFile: (id: string, path: string, text: string) => changeFiles(doc, id, 'other', (files) => files.writeTextFile(path, text)),
};
return () => doc?.off('update', counted);
}, [doc]);
const t = (key: string) => translate(language, key);
+43
View File
@@ -0,0 +1,43 @@
// A device or a documentation item being dragged (plan §7.4, M4): while the
// drag lasts, its place lives in this editor and goes to the others through
// the awareness (presence.ts); the shared document gets it once, when it is
// let go. Written into the document at each pointer move, a drag was 59
// updates a second, and each left about 18 bytes in the document's history
// for good — which every person who opens the project loads again (measured
// on test-filius, 04.10.2026).
import { normalizedFrame } from '@mdg-filius/core';
import { moveDevice, setDocumentationFrame, type TopologyView } from '@mdg-filius/doc';
import type * as Y from 'yjs';
export type Moving =
| { readonly kind: 'device'; readonly id: string; readonly x: number; readonly y: number }
| { readonly kind: 'doc'; readonly id: string; readonly x: number; readonly y: number; readonly width: number; readonly height: number };
/** The view with the drags in it: a device at its dragged place, an item in its dragged frame (as the document will hold it), the later of two drags of one thing; the view itself without any. */
export function movedView(view: TopologyView, moves: readonly Moving[]): TopologyView {
if (moves.length === 0) return view;
const devices = new Map<string, Moving & { kind: 'device' }>();
const items = new Map<string, Moving & { kind: 'doc' }>();
for (const move of moves) {
if (move.kind === 'device') devices.set(move.id, move);
else items.set(move.id, move);
}
return {
...view,
nodes: view.nodes.map((node) => {
const move = devices.get(node.id);
return move === undefined ? node : { ...node, x: move.x, y: move.y };
}),
docs: view.docs.map((item) => {
const move = items.get(item.id);
return move === undefined ? item : { ...item, ...normalizedFrame({ x: move.x, y: move.y, width: move.width, height: move.height }) };
}),
};
}
/** Writes a drag into the document, in one change. */
export function commitMove(doc: Y.Doc, move: Moving, origin: unknown): void {
if (move.kind === 'device') moveDevice(doc, move.id, move.x, move.y, origin);
else setDocumentationFrame(doc, move.id, move, origin);
}
+28 -3
View File
@@ -1,6 +1,7 @@
// Who else is in the project (plan §7.4), over Yjs awareness: each editor
// says who it is (F told it, `access.you`), the device it has chosen and the
// windows it has open — nothing else: no pointer (a tablet has none, and on
// says who it is (F told it, `access.you`), the device it has chosen, the
// windows it has open and what it is dragging, where to (moving.ts: the
// document gets the place only when it is let go) — nothing else: no pointer (a tablet has none, and on
// a desktop it distracts in class) and nothing that changes without a
// cause, such as a time (Scratch's lesson 1: else a few events a minute
// become a stream). What another editor says is read with care: a state of
@@ -11,6 +12,8 @@
// project; F does not check the awareness (it passes it on as Hocuspocus
// does). They see no more than before by it.
import type { Moving } from '../moving.js';
export const PRESENCE_VERSION = 1;
/** Who a person is, as F told their editor. */
@@ -29,6 +32,8 @@ export interface PresenceState {
readonly selection: string | null;
/** The windows it has open (windowKey). */
readonly windows: readonly string[];
/** What it is dragging, where to; none from an editor before drags were told. */
readonly moving: Moving | null;
}
export interface Peer {
@@ -50,17 +55,32 @@ export function colorOf(user: PresenceUser): string {
}
const isText = (value: unknown, most = 200): value is string => typeof value === 'string' && value.length <= most;
/** A place on the surface: Java's coordinates are whole numbers well within this (NaN and the infinities are not). */
const isPlace = (value: unknown): value is number => typeof value === 'number' && Math.abs(value) <= 1_000_000;
/** A drag as another editor tells it: null without one (also from an editor that does not tell drags), undefined when it is of another shape. */
function readMoving(value: unknown): Moving | null | undefined {
if (value === undefined || value === null) return null;
if (typeof value !== 'object') return undefined;
const { kind, id, x, y, width, height } = value as Record<string, unknown>;
if (!isText(id) || id === '' || !isPlace(x) || !isPlace(y)) return undefined;
if (kind === 'device') return { kind, id, x, y };
if (kind === 'doc' && isPlace(width) && isPlace(height)) return { kind, id, x, y, width, height };
return undefined;
}
/** Another editor's state, if it has the shape this one writes. */
export function readPresence(value: unknown): PresenceState | undefined {
if (typeof value !== 'object' || value === null) return undefined;
const { v, user, selection, windows } = value as Record<string, unknown>;
const moving = readMoving((value as Record<string, unknown>).moving);
if (v !== PRESENCE_VERSION || typeof user !== 'object' || user === null) return undefined;
const { id, name, color } = user as Record<string, unknown>;
if (!isText(id) || id === '' || !isText(name) || !isText(color, 32)) return undefined;
if (selection !== null && !isText(selection)) return undefined;
if (!Array.isArray(windows) || windows.length > 100 || !windows.every((each) => isText(each))) return undefined;
return { v: 1, user: { id, name, color }, selection, windows: windows as string[] };
if (moving === undefined) return undefined;
return { v: 1, user: { id, name, color }, selection, windows: windows as string[], moving };
}
/** The other editors in the project (this one left out), by name. */
@@ -93,6 +113,11 @@ export function selectionsOf(peers: readonly Peer[]): Map<string, string[]> {
return new Map([...chosen].map(([device, people]) => [device, [...people.values()]]));
}
/** What the others are dragging: each drag where it is now. */
export function movesOf(peers: readonly Peer[]): Moving[] {
return peers.flatMap(({ state }) => (state.moving === null ? [] : [state.moving]));
}
/** Each window open in other editors: who has it open, each person once. */
export function watchersOf(peers: readonly Peer[]): Map<string, PresenceUser[]> {
const open = new Map<string, Map<string, PresenceUser>>();
+46 -9
View File
@@ -1,8 +1,12 @@
// The editor's side of the awareness (presence.ts): the others as they come,
// change and go, and this editor's own state told — only when it changed,
// for setting the same state again would send it again.
// for setting the same state again would send it again. A drag is told at
// most every MOVING_MILLISECONDS (the place it has then; a pointer moves 60
// times a second, and every state goes to everyone in the project); its end,
// and any other change, at once.
import { useEffect, useMemo, useState } from 'react';
import type { Moving } from '../moving.js';
import { peersOf, type Peer, type PresenceUser } from './presence.js';
import type { Awareness } from './sharedDoc.js';
@@ -11,20 +15,53 @@ export interface Presence {
readonly you: PresenceUser;
/** The other editors in the project. */
readonly peers: readonly Peer[];
/** Tells the others the device chosen and the windows open (sent only when it changed). */
publish(selection: string | null, windows: readonly string[]): void;
/** Tells the others the device chosen, the windows open and what is being dragged (sent only when it changed, a drag at most every MOVING_MILLISECONDS). */
publish(selection: string | null, windows: readonly string[], moving: Moving | null): void;
}
/** Tells the others this editor's state, only when it changed (setting the same state again would send it again). */
export function presencePublisher(awareness: Pick<Awareness, 'setLocalState'>, you: PresenceUser): Presence['publish'] {
export const MOVING_MILLISECONDS = 50;
export interface PublisherClock {
now(): number;
later(run: () => void, milliseconds: number): unknown;
cancel(timer: unknown): void;
}
const realClock: PublisherClock = {
now: () => performance.now(),
later: (run, milliseconds) => setTimeout(run, milliseconds),
cancel: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
};
/** Tells the others this editor's state, only when it changed (setting the same state again would send it again), a drag at most every MOVING_MILLISECONDS. */
export function presencePublisher(awareness: Pick<Awareness, 'setLocalState'>, you: PresenceUser, clock: PublisherClock = realClock): Presence['publish'] {
let told: string | undefined;
return (selection, windows) => {
const state = { v: 1, user: you, selection, windows };
const key = JSON.stringify(state);
if (key === told) return;
let toldAt = -Infinity;
let waiting: { readonly timer: unknown; state: object; key: string } | undefined;
const send = (state: object, key: string) => {
if (waiting !== undefined) clock.cancel(waiting.timer);
waiting = undefined;
told = key;
toldAt = clock.now();
awareness.setLocalState(state);
};
return (selection, windows, moving) => {
const state = { v: 1, user: you, selection, windows, moving };
const key = JSON.stringify(state);
if (key === told) {
if (waiting !== undefined) clock.cancel(waiting.timer);
waiting = undefined;
return;
}
const due = toldAt + MOVING_MILLISECONDS - clock.now();
if (moving === null || due <= 0) return send(state, key);
// A drag told a moment ago: its latest place goes when the interval is over.
if (waiting !== undefined) Object.assign(waiting, { state, key });
else {
const timer = clock.later(() => waiting !== undefined && send(waiting.state, waiting.key), due);
waiting = { timer, state, key };
}
};
}
export function usePresence(awareness: Awareness, you: PresenceUser): Presence {
+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]);
});
});
@@ -82,9 +82,9 @@ describe("the editor's shared document", () => {
expect(miaReady.you).toEqual({ id: miaId, name: 'Mia', color: '#3366cc' });
expect(benReady.you).toMatchObject({ id: benId, name: 'Ben' });
// A reader is seen too: the awareness is not the document.
benReady.awareness.setLocalState({ v: 1, user: benReady.you, selection: 'router', windows: [] });
benReady.awareness.setLocalState({ v: 1, user: benReady.you, selection: 'router', windows: [], moving: null });
await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 1);
expect(peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID)[0]!.state).toEqual({ v: 1, user: benReady.you, selection: 'router', windows: [] });
expect(peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID)[0]!.state).toEqual({ v: 1, user: benReady.you, selection: 'router', windows: [], moving: null });
// Gone, he is gone for her too.
ben.close();
await until(() => peersOf(miaReady.awareness.getStates(), miaReady.awareness.clientID).length === 0);