feat(editor): F4.4b the design tools: palette, moving, cables, menus, undo
The palette in Java's order with Java's icons and names: a device dragged onto the surface lands with its icon at the drop point, a tapped one in the middle of the view; a new router asks for 2 to 8 interfaces as Java does. A tap selects, a drag moves (divided by the scale), a long press or the three-dot button opens Java's menu. The cable tool plugs by a drag or by two taps, shows WAN or LAN on a home router's halves and Java's messages when refused. Delete removes the selection; undo and redo by button and Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse and touch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,60 @@
|
||||
// The surface (plan §7.1): the devices with Java's icons and labels, the
|
||||
// cables, the documentation items, in an SVG that one moves with a finger or
|
||||
// the mouse and zooms with two fingers, a pinch on the trackpad, Ctrl and the
|
||||
// wheel or the buttons.
|
||||
// The surface (plan §7.1, §7.2): the devices with Java's icons and labels,
|
||||
// the cables, the documentation items, in an SVG; and what a finger or the
|
||||
// mouse does on it.
|
||||
//
|
||||
// - On the empty surface: a drag moves it, two fingers zoom, a tap clears
|
||||
// the selection. The wheel moves it, with Ctrl (a trackpad's pinch) zooms.
|
||||
// - On a device: a tap selects it, a drag moves it, a long press (or the
|
||||
// three-dot button of the selected one) opens its menu. With the cable
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// never shows it, Playwright does). The surface sets touch-action: none, so
|
||||
// that the browser's own zoom and scroll stay out of a gesture on it (M7).
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import 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, JAVA_LABEL_FONT, type DeviceBox } from './layout.js';
|
||||
import { fitted, middle, shifted, spread, zoomedAt, zoomIn, zoomOut, type Point, type Size, type Viewport } from './viewport.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';
|
||||
|
||||
/** How far a pointer moves before a press becomes a drag. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
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 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';
|
||||
|
||||
export interface CanvasHandle {
|
||||
/** The top left corner for a new device whose icon's middle is at a point of the screen; null outside the surface. */
|
||||
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;
|
||||
}
|
||||
|
||||
export interface CanvasProps {
|
||||
readonly view: TopologyView;
|
||||
readonly tool?: Tool;
|
||||
readonly selection?: Selection | null;
|
||||
/** The device a cable starts at, after a first tap with the cable tool. */
|
||||
readonly cableFrom?: { readonly id: string; readonly side: Side } | null;
|
||||
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;
|
||||
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;
|
||||
readonly onMenu?: (target: Selection, at: Point) => void;
|
||||
}
|
||||
|
||||
/** The label's width in Java's font, measured once per text. */
|
||||
function useTextWidth(): (text: string) => number {
|
||||
@@ -62,9 +99,32 @@ function Documentation({ item }: { readonly item: DocumentationView }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Device({ node, box, kindName }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string }) {
|
||||
function Device({ node, box, kindName, selected, pending }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string; readonly selected: boolean; readonly pending: boolean }) {
|
||||
return (
|
||||
<g className="device" data-node={node.id} data-box={`${box.x} ${box.y} ${box.width} ${box.height}`} role="img" aria-label={`${kindName}: ${node.label}`}>
|
||||
<g
|
||||
className="device"
|
||||
data-node={node.id}
|
||||
data-box={`${box.x} ${box.y} ${box.width} ${box.height}`}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
role="img"
|
||||
aria-label={`${kindName}: ${node.label}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{/* The whole button takes the pointer, as Java's label does. */}
|
||||
<rect x={box.x} y={box.y} width={box.width} height={box.height} fill="transparent" />
|
||||
{selected || pending ? (
|
||||
<rect
|
||||
x={box.x - 2}
|
||||
y={box.y - 2}
|
||||
width={box.width + 4}
|
||||
height={box.height + 4}
|
||||
rx={6}
|
||||
fill="color-mix(in srgb, var(--accent) 12%, transparent)"
|
||||
stroke="var(--accent)"
|
||||
strokeWidth={2}
|
||||
strokeDasharray={pending ? '6 4' : undefined}
|
||||
/>
|
||||
) : null}
|
||||
<image href={ICON_URLS[box.icon]} x={box.iconRect.x} y={box.iconRect.y} width={box.iconRect.width} height={box.iconRect.height} />
|
||||
<text x={box.label.x} y={box.label.y} textAnchor="middle" style={{ font: JAVA_LABEL_FONT, fill: 'var(--canvas-label)' }}>
|
||||
{node.label}
|
||||
@@ -73,19 +133,26 @@ function Device({ node, box, kindName }: { readonly node: NodeView; readonly box
|
||||
);
|
||||
}
|
||||
|
||||
export interface CanvasProps {
|
||||
readonly view: TopologyView;
|
||||
}
|
||||
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 };
|
||||
|
||||
export function Canvas({ view }: CanvasProps) {
|
||||
export const Canvas = forwardRef<CanvasHandle, CanvasProps>(function Canvas(
|
||||
{ view, tool = 'select', selection = null, cableFrom = null, onSelect, onMoveDevice, onMoveEnd, onCableStart, onCable, onMenu },
|
||||
handle,
|
||||
) {
|
||||
const t = useTranslate();
|
||||
const surface = useRef<SVGSVGElement>(null);
|
||||
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
|
||||
const [viewport, setViewport] = useState<Viewport | null>(null);
|
||||
const [band, setBand] = useState<{ readonly from: Point; readonly to: Point; readonly hint: string | null } | null>(null);
|
||||
const textWidth = useTextWidth();
|
||||
|
||||
const boxes = useMemo(() => new Map(view.nodes.map((node) => [node.id, deviceBox(node, textWidth(node.label))])), [view.nodes, textWidth]);
|
||||
const content = useMemo(() => bounds([...boxes.values(), ...view.docs.map((item) => ({ x: item.x, y: item.y, width: item.width, height: item.height }))]), [boxes, view.docs]);
|
||||
const kinds = useMemo(() => new Map(view.nodes.map((node) => [node.id, node.kind])), [view.nodes]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = surface.current;
|
||||
@@ -101,24 +168,105 @@ export function Canvas({ view }: CanvasProps) {
|
||||
}, [viewport, size, content]);
|
||||
|
||||
const current = viewport ?? { scale: 1, x: 0, y: 0 };
|
||||
const currentRef = useRef(current);
|
||||
currentRef.current = current;
|
||||
const local = useCallback((clientX: number, clientY: number): Point => {
|
||||
const rect = surface.current!.getBoundingClientRect();
|
||||
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||
}, []);
|
||||
|
||||
// Pointers down on the surface, and what they started.
|
||||
/** A new device's top left corner, its icon's middle at a point of the surface. */
|
||||
const cornerFor = useCallback(
|
||||
(kind: NodeKind, name: string, point: Point): Point => {
|
||||
const box = deviceBox({ kind, cloud: false, x: 0, y: 0 }, textWidth(name));
|
||||
return { x: point.x - box.width / 2, y: point.y - ICONS[iconOf(kind, false)].height / 2 };
|
||||
},
|
||||
[textWidth],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
handle,
|
||||
() => ({
|
||||
placeAt(kind, name, 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 cornerFor(kind, name, toSurface(currentRef.current, local(clientX, clientY)));
|
||||
},
|
||||
placeInView(kind, name) {
|
||||
const rect = surface.current?.getBoundingClientRect();
|
||||
return cornerFor(kind, name, toSurface(currentRef.current, { x: (rect?.width ?? 0) / 2, y: (rect?.height ?? 0) / 2 }));
|
||||
},
|
||||
}),
|
||||
[cornerFor, local],
|
||||
);
|
||||
|
||||
/** The device under a screen point, and which half of its button. */
|
||||
const deviceAt = useCallback(
|
||||
(point: Point): { id: string; side: Side } | null => {
|
||||
const surfacePoint = toSurface(currentRef.current, point);
|
||||
// The topmost (drawn last) first.
|
||||
for (const node of [...view.nodes].reverse()) {
|
||||
const box = boxes.get(node.id)!;
|
||||
if (surfacePoint.x >= box.x && surfacePoint.x <= box.x + box.width && surfacePoint.y >= box.y && surfacePoint.y <= box.y + box.height) {
|
||||
return { id: node.id, side: surfacePoint.x - box.x < box.width / 2 ? 'left' : 'right' };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[view.nodes, boxes],
|
||||
);
|
||||
|
||||
const hintAt = useCallback(
|
||||
(target: { id: string; side: Side } | null) => (target !== null && kinds.get(target.id) === 'gateway' ? t(target.side === 'left' ? 'cable.wan' : 'cable.lan') : null),
|
||||
[kinds, t],
|
||||
);
|
||||
|
||||
const pointers = useRef(new Map<number, Point>());
|
||||
const gesture = useRef<{ kind: 'press'; start: Point } | { kind: 'pan'; last: Point } | { kind: 'pinch'; spread: number; middle: Point } | null>(null);
|
||||
const gesture = useRef<Gesture | null>(null);
|
||||
const longPress = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const clearLongPress = () => {
|
||||
if (longPress.current !== null) clearTimeout(longPress.current);
|
||||
longPress.current = null;
|
||||
};
|
||||
const capture = (pointerId: number) => {
|
||||
if (surface.current !== null && !surface.current.hasPointerCapture(pointerId)) surface.current.setPointerCapture(pointerId);
|
||||
};
|
||||
|
||||
const onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const point = local(event.clientX, event.clientY);
|
||||
pointers.current.set(event.pointerId, point);
|
||||
if (pointers.current.size === 2) {
|
||||
// A second finger: a pinch, whatever the first one did.
|
||||
clearLongPress();
|
||||
setBand(null);
|
||||
const [first, second] = [...pointers.current.values()] as [Point, Point];
|
||||
gesture.current = { kind: 'pinch', spread: spread(first, second), middle: middle(first, second) };
|
||||
for (const id of pointers.current.keys()) if (!surface.current!.hasPointerCapture(id)) surface.current!.setPointerCapture(id);
|
||||
} else if (pointers.current.size === 1) {
|
||||
gesture.current = { kind: 'press', start: point };
|
||||
for (const id of pointers.current.keys()) capture(id);
|
||||
return;
|
||||
}
|
||||
if (pointers.current.size > 2) return;
|
||||
const target = event.target as Element;
|
||||
const linkId = target.closest('[data-link-hit]')?.getAttribute('data-link-hit');
|
||||
const device = deviceAt(point);
|
||||
if (device !== null) {
|
||||
const box = boxes.get(device.id)!;
|
||||
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;
|
||||
now.longPressed = true;
|
||||
onMenu?.({ kind: 'device', id: now.id }, now.start);
|
||||
}, LONG_PRESS_MILLISECONDS);
|
||||
} else if (linkId !== undefined && linkId !== null) {
|
||||
gesture.current = { kind: 'link', id: linkId, start: point, moved: false, longPressed: false };
|
||||
longPress.current = setTimeout(() => {
|
||||
const now = gesture.current;
|
||||
if (now?.kind !== 'link' || now.moved) return;
|
||||
now.longPressed = true;
|
||||
onMenu?.({ kind: 'link', id: now.id }, now.start);
|
||||
}, LONG_PRESS_MILLISECONDS);
|
||||
} else {
|
||||
gesture.current = { kind: 'background', start: point, moved: false, last: point };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,35 +276,85 @@ export function Canvas({ view }: CanvasProps) {
|
||||
pointers.current.set(event.pointerId, point);
|
||||
const now = gesture.current;
|
||||
if (now === null) return;
|
||||
if (now.kind === 'press') {
|
||||
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return;
|
||||
// Captured only now that it is a drag.
|
||||
surface.current!.setPointerCapture(event.pointerId);
|
||||
gesture.current = { kind: 'pan', last: now.start };
|
||||
}
|
||||
const active = gesture.current!;
|
||||
if (active.kind === 'pan') {
|
||||
setViewport((before) => shifted(before ?? current, { x: point.x - active.last.x, y: point.y - active.last.y }));
|
||||
gesture.current = { kind: 'pan', last: point };
|
||||
} else if (active.kind === 'pinch' && pointers.current.size === 2) {
|
||||
if (now.kind === 'pinch') {
|
||||
if (pointers.current.size !== 2) return;
|
||||
const [first, second] = [...pointers.current.values()] as [Point, Point];
|
||||
const nextSpread = spread(first, second);
|
||||
const nextMiddle = middle(first, second);
|
||||
setViewport((before) => {
|
||||
const base = before ?? current;
|
||||
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, active.spread)), active.middle);
|
||||
return shifted(zoomed, { x: nextMiddle.x - active.middle.x, y: nextMiddle.y - active.middle.y });
|
||||
const base = before ?? currentRef.current;
|
||||
const zoomed = zoomedAt(base, base.scale * (nextSpread / Math.max(1, now.spread)), now.middle);
|
||||
return shifted(zoomed, { x: nextMiddle.x - now.middle.x, y: nextMiddle.y - now.middle.y });
|
||||
});
|
||||
gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle };
|
||||
return;
|
||||
}
|
||||
if (!now.moved) {
|
||||
if (Math.hypot(point.x - now.start.x, point.y - now.start.y) < DRAG_THRESHOLD) return;
|
||||
// A drag from now on: captured only now (see above).
|
||||
now.moved = true;
|
||||
clearLongPress();
|
||||
capture(event.pointerId);
|
||||
}
|
||||
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 };
|
||||
now.last = point;
|
||||
setViewport((before) => shifted(before ?? currentRef.current, step));
|
||||
} else if (now.kind === 'device' && !now.longPressed) {
|
||||
if (tool === 'cable') {
|
||||
const from = boxes.get(now.id)!.center;
|
||||
const target = deviceAt(point);
|
||||
setBand({ from, to: toSurface(currentRef.current, point), hint: target?.id === now.id ? null : hintAt(target) });
|
||||
} else {
|
||||
const scale = currentRef.current.scale;
|
||||
onMoveDevice?.(now.id, now.origin.x + (point.x - now.start.x) / scale, now.origin.y + (point.y - now.start.y) / scale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const point = pointers.current.get(event.pointerId) ?? local(event.clientX, event.clientY);
|
||||
pointers.current.delete(event.pointerId);
|
||||
if (surface.current?.hasPointerCapture(event.pointerId) === true) surface.current.releasePointerCapture(event.pointerId);
|
||||
// A pinch that loses a finger goes on as a pan with the other.
|
||||
clearLongPress();
|
||||
const now = gesture.current;
|
||||
const [remaining] = [...pointers.current.values()];
|
||||
gesture.current = remaining === undefined ? null : { kind: 'pan', last: remaining };
|
||||
// A pinch that loses a finger goes on as a move of the surface with the other.
|
||||
gesture.current = remaining === undefined ? null : { kind: 'background', start: remaining, moved: true, last: remaining };
|
||||
if (now === null || now.kind === 'pinch' || event.type === 'pointercancel') {
|
||||
setBand(null);
|
||||
return;
|
||||
}
|
||||
if (now.kind === 'background') {
|
||||
if (!now.moved) {
|
||||
onSelect?.(null);
|
||||
if (cableFrom !== null) onCableStart?.(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (now.kind === 'link') {
|
||||
if (!now.moved && !now.longPressed) onSelect?.({ kind: 'link', id: now.id });
|
||||
return;
|
||||
}
|
||||
if (now.longPressed) return;
|
||||
if (tool === 'cable') {
|
||||
setBand(null);
|
||||
const from = { id: now.id, side: now.side };
|
||||
if (now.moved) {
|
||||
const target = deviceAt(point);
|
||||
if (target !== null && target.id !== now.id) onCable?.(from, target);
|
||||
} else if (cableFrom === null) {
|
||||
onCableStart?.(from);
|
||||
} else if (cableFrom.id === now.id) {
|
||||
onCableStart?.(null);
|
||||
} else {
|
||||
onCable?.(cableFrom, from);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (now.moved) onMoveEnd?.();
|
||||
else onSelect?.({ kind: 'device', id: now.id });
|
||||
};
|
||||
|
||||
// The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves.
|
||||
@@ -177,20 +375,37 @@ export function Canvas({ view }: CanvasProps) {
|
||||
return () => element.removeEventListener('wheel', onWheel);
|
||||
}, [local]);
|
||||
|
||||
// The three-dot button of what is selected — at a device's top right corner, in a cable's middle — in screen pixels (44 px for a finger).
|
||||
const menuAnchor = ((): Point | null => {
|
||||
if (selection?.kind === 'device') {
|
||||
const box = boxes.get(selection.id);
|
||||
return box === undefined ? null : { x: box.x + box.width, y: box.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 ?? '')];
|
||||
return source === undefined || target === undefined ? null : middle(source.center, target.center);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const menuButton = menuAnchor === null ? null : toScreen(current, menuAnchor);
|
||||
|
||||
const centre = { x: size.width / 2, y: size.height / 2 };
|
||||
const style: CSSProperties = { width: '100%', height: '100%', display: 'block', background: 'var(--canvas-bg)', touchAction: 'none', userSelect: 'none' };
|
||||
return (
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
|
||||
<svg
|
||||
ref={surface}
|
||||
className="filius-canvas"
|
||||
data-tool={tool}
|
||||
role="application"
|
||||
aria-label={t('canvas.label')}
|
||||
style={style}
|
||||
style={{ ...style, cursor: tool === 'cable' ? 'crosshair' : 'default' }}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
|
||||
<g className="docs">
|
||||
@@ -198,29 +413,79 @@ export function Canvas({ view }: CanvasProps) {
|
||||
<Documentation key={item.id} item={item} />
|
||||
))}
|
||||
</g>
|
||||
<g className="cables" fill="none" strokeWidth={2}>
|
||||
<g className="cables" fill="none">
|
||||
{view.links.map((link) => {
|
||||
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
|
||||
// Drawn tolerantly: a cable whose device is gone (before the janitor) is left out.
|
||||
if (source === undefined || target === undefined) return null;
|
||||
const d = cablePath(source.center, target.center);
|
||||
const selected = selection?.kind === 'link' && selection.id === link.id;
|
||||
return (
|
||||
<path
|
||||
key={link.id}
|
||||
data-link={link.id}
|
||||
d={cablePath(source.center, target.center)}
|
||||
stroke={link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
strokeDasharray={link.wireless ? '10' : undefined}
|
||||
/>
|
||||
<g key={link.id}>
|
||||
<path
|
||||
data-link={link.id}
|
||||
data-selected={selected ? 'true' : undefined}
|
||||
d={d}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
stroke={selected ? 'var(--accent)' : link.wireless ? 'var(--canvas-cable-wireless)' : 'var(--canvas-cable)'}
|
||||
strokeDasharray={link.wireless ? '10' : undefined}
|
||||
/>
|
||||
{/* A wide, invisible line to tap: 2 px is no target for a finger. */}
|
||||
<path data-link-hit={link.id} d={d} stroke="transparent" strokeWidth={16 / current.scale} style={{ cursor: 'pointer' }} />
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
<g className="devices">
|
||||
{view.nodes.map((node) => (
|
||||
<Device key={node.id} node={node} box={boxes.get(node.id)!} kindName={t(`device.${node.kind}`)} />
|
||||
<Device
|
||||
key={node.id}
|
||||
node={node}
|
||||
box={boxes.get(node.id)!}
|
||||
kindName={t(`device.${node.kind}`)}
|
||||
selected={selection?.kind === 'device' && selection.id === node.id}
|
||||
pending={cableFrom?.id === node.id}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
{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" />
|
||||
{band.hint === null ? null : (
|
||||
<text x={band.to.x + 12 / current.scale} y={band.to.y - 12 / current.scale} style={{ font: JAVA_LABEL_FONT, fill: 'var(--accent)' }} data-hint>
|
||||
{band.hint}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
</svg>
|
||||
{menuButton === null || selection === null ? null : (
|
||||
<button
|
||||
type="button"
|
||||
className="device-menu-button"
|
||||
aria-label={t('design.actions')}
|
||||
title={t('design.actions')}
|
||||
onClick={(event) => onMenu?.(selection, local(event.clientX, event.clientY))}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: Math.min(Math.max(0, menuButton.x - 22), Math.max(0, size.width - 44)),
|
||||
top: Math.min(Math.max(0, menuButton.y - 22), Math.max(0, size.height - 44)),
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
border: '1px solid var(--border-default)',
|
||||
background: 'var(--surface-raised)',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 20,
|
||||
lineHeight: 1,
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent)',
|
||||
}}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
)}
|
||||
<ZoomBar
|
||||
scale={current.scale}
|
||||
onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))}
|
||||
@@ -229,4 +494,4 @@ export function Canvas({ view }: CanvasProps) {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user