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
@@ -0,0 +1,130 @@
// The documentation mode's sidebar (Java's GUIDocumentationSidebar): „Text“
// and „Gliederungsfläche“ with Java's icons. An item is dragged onto the
// surface, where it lands with its middle under the pointer, as Java's does;
// tapped, it lands in the middle of the view (Java's tap makes nothing).
import { useRef, useState, type CSSProperties } from 'react';
import type { DocumentationItemKind } from '@mdg-filius/core';
import { DRAG_THRESHOLD } from '../canvas/Canvas.js';
import { useTranslate } from '../i18n/i18n.js';
import addRectangle from '../assets/documentation/add_small.png';
import addText from '../assets/documentation/add_text_small.png';
export interface DocumentationSidebarProps {
/** An item dropped at a point of the screen, or tapped (no point). */
readonly onItem: (kind: DocumentationItemKind, at: { readonly clientX: number; readonly clientY: number } | null) => void;
}
const ITEMS: readonly { readonly kind: DocumentationItemKind; readonly icon: string; readonly label: string }[] = [
{ kind: 'text', icon: addText, label: 'docu.text' },
{ kind: 'rectangle', icon: addRectangle, label: 'docu.rectangle' },
];
const item: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
minHeight: 64,
padding: '6px 4px',
border: '2px solid transparent',
borderRadius: 8,
background: 'transparent',
color: 'var(--text-primary)',
font: 'inherit',
fontSize: 12,
textAlign: 'center',
overflowWrap: 'anywhere',
cursor: 'grab',
touchAction: 'none',
};
export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) {
const t = useTranslate();
const [ghost, setGhost] = useState<{ kind: DocumentationItemKind; x: number; y: number } | null>(null);
const press = useRef<{ kind: DocumentationItemKind; x: number; y: number; dragging: boolean } | null>(null);
/** The click the browser sends after a drag ends on the button: not a tap. */
const afterDrag = useRef(false);
const onPointerDown = (kind: DocumentationItemKind) => (event: React.PointerEvent<HTMLButtonElement>) => {
press.current = { kind, x: event.clientX, y: event.clientY, dragging: false };
// The button is the click's own target: capturing at once takes no click from anything inside it.
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: React.PointerEvent<HTMLButtonElement>) => {
const now = press.current;
if (now === null) return;
if (!now.dragging) {
if (Math.hypot(event.clientX - now.x, event.clientY - now.y) < DRAG_THRESHOLD) return;
now.dragging = true;
}
setGhost({ kind: now.kind, x: event.clientX, y: event.clientY });
};
const onPointerUp = (event: React.PointerEvent<HTMLButtonElement>) => {
const now = press.current;
press.current = null;
setGhost(null);
if (now?.dragging !== true) return;
afterDrag.current = true;
onItem(now.kind, { clientX: event.clientX, clientY: event.clientY });
};
return (
<nav
aria-label={t('docu.sidebar')}
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: 6,
overflowY: 'auto',
background: 'var(--surface-card)',
borderRight: '1px solid var(--border-default)',
width: 124,
flexShrink: 0,
}}
>
{ITEMS.map((each) => (
<button
key={each.kind}
type="button"
style={item}
data-docu={each.kind}
onPointerDown={onPointerDown(each.kind)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => {
press.current = null;
setGhost(null);
}}
onClick={() => {
if (afterDrag.current) afterDrag.current = false;
else onItem(each.kind, null);
}}
>
<img src={each.icon} alt="" width={40} height={40} draggable={false} />
{t(each.label)}
</button>
))}
{ghost === null ? null : (
// The item's own size, its middle under the pointer, as Java drags it.
<div
data-ghost
style={{
position: 'fixed',
left: ghost.x,
top: ghost.y,
width: 100,
height: 50,
transform: 'translate(-50%, -50%)',
border: `1px ${ghost.kind === 'text' ? 'solid rgb(179, 179, 179)' : 'solid rgb(163, 163, 163)'}`,
borderRadius: 5,
background: ghost.kind === 'rectangle' ? 'rgba(204, 204, 204, 0.78)' : 'transparent',
pointerEvents: 'none',
zIndex: 10,
}}
/>
)}
</nav>
);
}