feat: F4.8b the documentation mode in the editor

Java's third mode button, the sidebar's text and structure field (dropped
with its middle under the pointer), items over the network that are moved,
resized by Java's rule, typed into on their Y.Text, coloured and set in
size and style from Java's menu, deleted; Java's paint per mode, measured
in F4.8a.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 11:27:25 +00:00
co-authored by Claude Opus 5.5
parent 56cdb4926e
commit de03f6bb96
17 changed files with 1034 additions and 42 deletions
+193 -28
View File
@@ -9,6 +9,11 @@
// tool a drag from one device to another plugs a cable, and so do two
// taps, one on each (Java's way).
// - On a cable: a tap selects it, a long press opens its menu.
// - In the documentation mode the documentation items lie over the network
// and take the pointer (Java's documentation panel moves to the top): a
// tap gives one the focus, a drag on a rectangle (on a text: on the band
// around it while it has the focus) moves it, on its edge resizes it,
// a long press opens its menu. The devices take nothing.
//
// The pointer is captured only once it has moved: captured at once, it would
// take the click from whatever lies under it (pointer-capture trap; jsdom
@@ -16,20 +21,21 @@
// that the browser's own zoom and scroll stay out of a gesture on it (M7).
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import type { NodeKind } from '@mdg-filius/core';
import { resizedFrame, type NodeKind } from '@mdg-filius/core';
import type { DocumentationView, NodeView, TopologyView } from '@mdg-filius/doc';
import { useTranslate } from '../i18n/i18n.js';
import { ICON_URLS } from './icons.js';
import { bounds, cablePath, deviceBox, ICONS, iconOf, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
import { fitted, middle, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.js';
import { ZoomBar } from './ZoomBar.js';
import { EDGE_BAND, edgesAt, FOCUS_BAND, FOCUS_BORDER, fontFamily, holdFrame, rectangleBorder, rectangleFill, TEXT_BORDER, textColor, type Frame } from './documentationPaint.js';
/** How far a pointer moves before a press becomes a drag. */
export const DRAG_THRESHOLD = 4;
/** How long a press lasts before it opens the menu. */
export const LONG_PRESS_MILLISECONDS = 500;
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string };
export type Selection = { readonly kind: 'device'; readonly id: string } | { readonly kind: 'link'; readonly id: string } | { readonly kind: 'doc'; readonly id: string };
export type Tool = 'select' | 'cable';
/** Where on a home router's button a cable meets it: Java takes the left half for WAN, the right for LAN. */
export type Side = 'left' | 'right';
@@ -39,6 +45,19 @@ export interface CanvasHandle {
placeAt(kind: NodeKind, name: string, clientX: number, clientY: number): Point | null;
/** The same, in the middle of what is in view. */
placeInView(kind: NodeKind, name: string): Point;
/** The drawing's point under a point of the screen; null outside the surface. */
surfacePointAt(clientX: number, clientY: number): Point | null;
/** The drawing's point in the middle of what is in view. */
surfaceCentre(): Point;
}
/** Where a focused text is typed into: its frame on the screen and the scale it is drawn at. */
export interface ScreenFrame {
readonly left: number;
readonly top: number;
readonly width: number;
readonly height: number;
readonly scale: number;
}
export interface CanvasProps {
@@ -59,9 +78,13 @@ export interface CanvasProps {
/**
* The simulation: a tap on a device activates it (a computer's desktop),
* a long press opens its menu; nothing moves, nothing is selected, as in
* Java's simulation mode.
* Java's simulation mode. The documentation mode: see above.
*/
readonly mode?: 'design' | 'simulation';
readonly mode?: 'design' | 'simulation' | 'documentation';
/** 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. */
readonly renderDocText?: (item: DocumentationView, frame: ScreenFrame) => React.ReactNode;
/** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */
readonly activeLinks?: ReadonlySet<string>;
readonly onActivate?: (id: string) => void;
@@ -87,27 +110,57 @@ function useTextWidth(): (text: string) => number {
);
}
const rgba = (color: DocumentationView['color']) => `rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
function Documentation({ item }: { readonly item: DocumentationView }) {
function Documentation({ item, documenting, focused }: { readonly item: DocumentationView; readonly documenting: boolean; readonly focused: boolean }) {
const hold = holdFrame(item, focused);
// The focus (documentation mode): a translucent blue band with a blue border, around a text, inside a rectangle.
const focus = !focused ? null : (
<rect className="doc-focus" x={hold.x + 1} y={hold.y + 1} width={hold.width - 2} height={hold.height - 2} rx={5} fill={FOCUS_BAND} stroke={FOCUS_BORDER} strokeWidth={2} />
);
if (item.kind === 'rectangle') {
return <rect className="doc-rectangle" x={item.x} y={item.y} width={item.width} height={item.height} fill={rgba(item.color)} data-doc={item.id} />;
const inset = focused ? EDGE_BAND : 1;
return (
<g data-doc={item.id} data-focused={focused ? 'true' : undefined}>
{focus}
<rect
className="doc-rectangle"
x={item.x + inset}
y={item.y + inset}
width={Math.max(0, item.width - 2 * inset)}
height={Math.max(0, item.height - 2 * inset)}
rx={5}
fill={rectangleFill(item.color, documenting)}
stroke={focused ? 'none' : rectangleBorder(item.color)}
strokeWidth={1}
/>
</g>
);
}
return (
<foreignObject x={item.x} y={item.y} width={item.width} height={item.height} data-doc={item.id}>
<div
style={{
color: rgba(item.color),
font: `${item.bold ? 'bold ' : ''}${item.size}px "${item.font}", Dialog, sans-serif`,
whiteSpace: 'pre-wrap',
overflow: 'hidden',
width: '100%',
height: '100%',
}}
>
{item.text}
</div>
</foreignObject>
<g data-doc={item.id} data-focused={focused ? 'true' : undefined}>
{focus}
{/* While it has the focus, the field typed into draws its words. */}
<foreignObject x={item.x} y={item.y} width={item.width} height={item.height}>
<div
className="doc-text"
style={{
boxSizing: 'border-box',
padding: 1,
color: textColor(item.color),
font: `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`,
whiteSpace: 'pre-wrap',
overflowWrap: 'break-word',
overflow: 'hidden',
width: '100%',
height: '100%',
border: documenting && !focused ? `1px solid ${TEXT_BORDER}` : undefined,
visibility: focused ? 'hidden' : undefined,
}}
>
{item.text}
</div>
</foreignObject>
</g>
);
}
@@ -149,10 +202,37 @@ type Gesture =
| { readonly kind: 'background'; readonly start: Point; moved: boolean; last: Point }
| { readonly kind: 'pinch'; spread: number; middle: Point }
| { readonly kind: 'device'; readonly id: string; readonly start: Point; readonly origin: Point; readonly side: Side; moved: boolean; longPressed: boolean }
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean };
| { readonly kind: 'link'; readonly id: string; readonly start: Point; moved: boolean; longPressed: boolean }
| {
readonly kind: 'doc';
readonly id: string;
readonly start: Point;
readonly origin: Frame;
/** The edges a drag moves; none: it moves the item; null: it does nothing (a text without the focus). */
readonly edges: { left: boolean; right: boolean; top: boolean; bottom: boolean } | null;
moved: boolean;
longPressed: boolean;
};
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu, highlightedLink = null, mode = 'design', activeLinks = NO_LINKS, onActivate },
{
view,
tool = 'select',
selection = null,
cableFrom = null,
onSelect,
onMoveDevice,
onMoveEnd,
onCableStart,
onCable,
onMenu,
highlightedLink = null,
mode = 'design',
activeLinks = NO_LINKS,
onActivate,
onDocFrame,
renderDocText,
},
handle,
) {
const t = useTranslate();
@@ -208,6 +288,15 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const rect = surface.current?.getBoundingClientRect();
return cornerFor(kind, name, toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 }));
},
surfacePointAt(clientX, clientY) {
const rect = surface.current?.getBoundingClientRect();
if (rect === undefined || clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) return null;
return toSurface(currentRef.current, local(clientX, clientY));
},
surfaceCentre() {
const rect = surface.current?.getBoundingClientRect();
return toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 });
},
}),
[cornerFor, local],
);
@@ -234,6 +323,21 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
);
const simulating = mode === 'simulation';
const documenting = mode === 'documentation';
const focusedDoc = documenting && selection?.kind === 'doc' ? selection.id : null;
/** The documentation item under a screen point, the topmost first (drawn last), with the focused text's band. */
const docAt = useCallback(
(point: Point): { item: DocumentationView; hold: Frame } | null => {
const surfacePoint = toSurface(currentRef.current, point);
for (const item of [...view.docs].reverse()) {
const hold = holdFrame(item, item.id === focusedDoc);
if (surfacePoint.x >= hold.x && surfacePoint.x <= hold.x + hold.width && surfacePoint.y >= hold.y && surfacePoint.y <= hold.y + hold.height) return { item, hold };
}
return null;
},
[view.docs, focusedDoc],
);
const pointers = useRef(new Map<number, Point>());
const gesture = useRef<Gesture | null>(null);
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -260,6 +364,27 @@ 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) {
const found = docAt(point);
if (found === null) {
gesture.current = { kind: 'background', start: point, moved: false, last: point };
return;
}
const { item, hold } = found;
// An edge's band: Java's, wider for a finger at a small zoom, but never more than a third of the item (its middle moves it);
// a text without the focus is only chosen (Java moves it in its band).
const band = Math.max(EDGE_BAND, Math.min(10 / currentRef.current.scale, Math.min(hold.width, hold.height) / 3));
const movable = item.kind === 'rectangle' || item.id === focusedDoc;
const edges = movable ? edgesAt(hold, toSurface(currentRef.current, point), band) : null;
gesture.current = { kind: 'doc', id: item.id, start: point, origin: { x: item.x, y: item.y, width: item.width, height: item.height }, edges, moved: false, longPressed: false };
longPress.current = setTimeout(() => {
const now = gesture.current;
if (now?.kind !== 'doc' || now.moved) return;
now.longPressed = true;
onMenu?.({ kind: 'doc', id: now.id }, now.start);
}, LONG_PRESS_MILLISECONDS);
return;
}
const device = deviceAt(point);
if (device !== null) {
const box = boxes.get(device.id)!;
@@ -309,6 +434,21 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
clearLongPress();
capture(event.pointerId);
}
if (now.kind === 'doc') {
if (now.longPressed || now.edges === null) return;
const scale = currentRef.current.scale;
const delta = { x: (point.x - now.start.x) / scale, y: (point.y - now.start.y) / scale };
const { left, right, top, bottom } = now.edges;
const kind = view.docs.find((item) => item.id === now.id)?.kind ?? 'rectangle';
if (!left && !right && !top && !bottom) {
onDocFrame?.(now.id, { ...now.origin, x: now.origin.x + delta.x, y: now.origin.y + delta.y });
} else {
// The moved edge follows the pointer from where it was (JDocuElement.mouseDragged).
const edge = { x: (left ? now.origin.x : now.origin.x + now.origin.width) + delta.x, y: (top ? now.origin.y : now.origin.y + now.origin.height) + delta.y };
onDocFrame?.(now.id, resizedFrame(now.origin, kind, now.edges, edge));
}
return;
}
if (now.kind === 'background') {
// The step taken now: the updater runs later, when `last` has moved on.
const step = { x: point.x - now.last.x, y: point.y - now.last.y };
@@ -339,6 +479,12 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
setBand(null);
return;
}
if (now.kind === 'doc') {
if (now.longPressed) return;
if (now.moved) onMoveEnd?.();
else onSelect?.({ kind: 'doc', id: now.id });
return;
}
if (now.kind === 'background') {
if (!now.moved && !simulating) {
onSelect?.(null);
@@ -398,6 +544,10 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
const box = boxes.get(selection.id);
return box === undefined ? null : { x: box.x + box.width, y: box.y };
}
if (selection?.kind === 'doc') {
const item = view.docs.find((each) => each.id === selection.id);
return item === undefined ? null : { x: item.x + item.width, y: item.y };
}
if (selection?.kind === 'link') {
const link = view.links.find((each) => each.id === selection.id);
const [source, target] = [boxes.get(link?.sourceNode ?? ''), boxes.get(link?.targetNode ?? '')];
@@ -426,11 +576,13 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
onContextMenu={(event) => event.preventDefault()}
>
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
<g className="docs">
{view.docs.map((item) => (
<Documentation key={item.id} item={item} />
))}
</g>
{documenting ? null : (
<g className="docs">
{view.docs.map((item) => (
<Documentation key={item.id} item={item} documenting={false} focused={false} />
))}
</g>
)}
<g className="cables" fill="none">
{view.links.map((link) => {
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
@@ -468,6 +620,13 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
/>
))}
</g>
{documenting ? (
<g className="docs">
{view.docs.map((item) => (
<Documentation key={item.id} item={item} documenting focused={item.id === focusedDoc} />
))}
</g>
) : null}
{band === null ? null : (
<g className="cable-preview" pointerEvents="none">
<line x1={band.from.x} y1={band.from.y} x2={band.to.x} y2={band.to.y} stroke="var(--accent)" strokeWidth={2 / current.scale} strokeDasharray="6 4" />
@@ -480,6 +639,12 @@ export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
)}
</g>
</svg>
{(() => {
const item = focusedDoc === null ? undefined : view.docs.find((each) => each.id === focusedDoc && each.kind === 'text');
if (item === undefined || renderDocText === undefined) return null;
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 : (
<button
type="button"
@@ -0,0 +1,79 @@
// How Java paints a documentation item (JDocuElement.setColor, paintComponent;
// measured with tools/java-oracle's DocuRun): a rectangle in the
// documentation mode in its colour at an opacity of 200, else opaque and a
// fifth of the way to white; its border four fifths of its colour; corners
// rounded (an arc of 10). A text has a light grey border in the
// documentation mode only. What has the focus there shows a translucent
// blue band and a blue border; a text's frame then grows around it (6 to
// the left and up, 12 wider, 28 higher), the band to take hold of.
import type { DocumentationItemKind } from '@mdg-filius/core';
import type { DocumentationView } from '@mdg-filius/doc';
type Color = DocumentationView['color'];
const byte = (unit: number) => Math.round(unit * 255);
/** Java's opacity of a rectangle in the documentation mode (transparentColor). */
export const DOCUMENTATION_OPACITY = 200;
/** JDocuElement.panelFocusColor and borderFocusColor; the text's border (textPanelBorderNormalColor). */
export const FOCUS_BAND = 'rgba(56, 152, 248, 0.3)';
export const FOCUS_BORDER = 'rgb(122, 186, 250)';
export const TEXT_BORDER = 'rgb(179, 179, 179)';
/** The width of the band an edge is taken by (TEXT_BORDER_WIDTH). */
export const EDGE_BAND = 7;
/** A rectangle's fill: its colour at Java's opacity in the documentation mode, else opaque, a fifth of the way to white. */
export function rectangleFill(color: Color, documenting: boolean): string {
const [red, green, blue] = [byte(color.red), byte(color.green), byte(color.blue)];
if (documenting) return `rgba(${red}, ${green}, ${blue}, ${DOCUMENTATION_OPACITY / 255})`;
const lighter = (value: number) => 255 + Math.trunc(((value - 255) * 8) / 10);
return `rgb(${lighter(red)}, ${lighter(green)}, ${lighter(blue)})`;
}
/** A rectangle's border: four fifths of its colour. */
export function rectangleBorder(color: Color): string {
const darker = (unit: number) => Math.trunc((byte(unit) * 4) / 5);
return `rgb(${darker(color.red)}, ${darker(color.green)}, ${darker(color.blue)})`;
}
export function textColor(color: Color): string {
return `rgba(${byte(color.red)}, ${byte(color.green)}, ${byte(color.blue)}, ${color.alpha})`;
}
/** Java's logical font names as the browser has them; any other a font of that name, else sans-serif. */
export function fontFamily(name: string): string {
switch (name.toLowerCase()) {
case 'dialog':
case 'sansserif':
return 'sans-serif';
case 'serif':
return 'serif';
case 'monospaced':
case 'dialoginput':
return 'monospace';
default:
return `"${name.replaceAll('"', '')}", sans-serif`;
}
}
export interface Frame {
readonly x: number;
readonly y: number;
readonly width: number;
readonly height: number;
}
/** Where a focused item is taken hold of: a rectangle by itself, a text by its frame grown around it. */
export function holdFrame(item: Frame & { readonly kind: DocumentationItemKind }, focused: boolean): Frame {
if (item.kind === 'rectangle' || !focused) return item;
return { x: item.x - 6, y: item.y - 6, width: item.width + 12, height: item.height + 28 };
}
/** The edges within `band` of a point inside a frame (none: the middle, which moves it); of two opposite ones the drag takes the left or upper. */
export function edgesAt(frame: Frame, point: { readonly x: number; readonly y: number }, band: number): { left: boolean; right: boolean; top: boolean; bottom: boolean } {
const left = point.x - frame.x <= band;
const right = frame.x + frame.width - point.x <= band;
const top = point.y - frame.y <= band;
const bottom = frame.y + frame.height - point.y <= band;
return { left, right, top, bottom };
}