feat: F5.3b the editor on a project of server F

?project=<uuid> opens the shared document over /sync and the room over
/sim (RemoteSimHost: messages wait for the socket, a lost socket comes
back with what it watched, F's refusal or parting ends it); instead of
the editor, a word why when F refuses or lets go. F tells each
connection its access at once. Who only reads sees everything and
changes nothing: no palette or undo, a locked surface, the sheet's
fields disabled (tabs stay), no starting or stopping; thawed, the
document comes anew. The toolbar names who started the simulation.
Refusals carry no message, so Hocuspocus writes nothing outside F's
JSON log. A browser test runs three people against a real F.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:00:18 +00:00
co-authored by Claude Opus 5.5
parent 8cfc518f0c
commit f52852493a
28 changed files with 1142 additions and 58 deletions
+19 -4
View File
@@ -81,6 +81,12 @@ export interface CanvasProps {
* Java's simulation mode. The documentation mode: see above.
*/
readonly mode?: 'design' | 'simulation' | 'documentation';
/**
* Who only reads (on F): a tap still chooses a device (its sheet shows
* what it holds), but nothing moves, no cable is begun or chosen, no
* documentation item is chosen or changed, and nothing has a menu.
*/
readonly locked?: boolean;
/** A documentation item moved or resized: its new frame. */
readonly onDocFrame?: (id: string, frame: Frame) => void;
/** The field a focused text is typed into, over the surface at its frame. */
@@ -228,6 +234,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
onMenu,
highlightedLink = null,
mode = 'design',
locked = false,
activeLinks = NO_LINKS,
onActivate,
onDocFrame,
@@ -364,6 +371,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (pointers.current.size > 2) return;
const target = event.target as Element;
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
if (documenting && locked) {
gesture.current = { kind: 'background', start: point, moved: false, last: point };
return;
}
if (documenting) {
const found = docAt(point);
if (found === null) {
@@ -391,11 +402,11 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
gesture.current = { kind: 'device', id: device.id, start: point, origin: { x: box.x, y: box.y }, side: device.side, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'device' || now.moved) return;
if (now?.kind !== 'device' || now.moved || (locked && !simulating)) return;
now.longPressed = true;
onMenu?.({ kind: 'device', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
} else if (linkId !== undefined && linkId !== null && !simulating) {
} else if (linkId !== undefined && linkId !== null && !simulating && !locked) {
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
@@ -454,7 +465,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
now.last = point;
setViewport((before) => shifted(before ?? currentRef.current, step));
} else if (now.kind === 'device' && !now.longPressed && !simulating) {
} else if (now.kind === 'device' && !now.longPressed && !simulating && !locked) {
if (tool === 'cable') {
const from = boxes.get(now.id)!.center;
const target = deviceAt(point);
@@ -501,6 +512,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
if (!now.moved) onActivate?.(now.id);
return;
}
if (locked) {
if (!now.moved) onSelect?.({ kind: 'device', id: now.id });
return;
}
if (tool === 'cable') {
setBand(null);
const from = { id: now.id, side: now.side };
@@ -645,7 +660,7 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const corner = toScreen(current, { x: item.x, y: item.y });
return renderDocText(item, { left: corner.x, top: corner.y, width: item.width * current.scale, height: item.height * current.scale, scale: current.scale });
})()}
{menuButton === null || selection === null || simulating ? null : (
{menuButton === null || selection === null || simulating || locked ? null : (
<button
type="button"
className="device-menu-button"