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:
@@ -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 : (
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>>();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user