Files
mdg-filius/packages/editor/src/design/DocumentationSidebar.tsx
T
julian.kiedaischandClaude Opus 5.5 7e68414bb2 feat: F4.8c the documentation mode's image export, as Java's
ExportRun measures Java's print panel (GUIContainer.prepareExportPanel) and
image on four projects and an empty one: 10 to spare around, a light grey
footer 20 below the lowest element, 20 below it, a transparent background,
the documentation painted as in the design. The sidebar gains Java's
"Exportieren": the surface painted with Canvas 2D (an SVG with a
foreignObject taints the canvas in WebKit) and saved under the project's
name; Java's message when saving fails. Not adopted: Java's empty image of
an empty project (its footer overflows). The editor takes a projectName;
the capture export shares the download helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:54:09 +00:00

158 lines
5.7 KiB
TypeScript

// 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).
// „Exportieren“ saves the surface as an image (F4.8c); if that fails, Java's
// „Fehler beim Speichern aufgetreten!“ shows below it.
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';
import exportIcon from '../assets/documentation/download_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;
/** „Exportieren“: the surface saved as an image; rejected, it failed. */
readonly onExport: () => Promise<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, onExport }: 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 [exporting, setExporting] = useState(false);
const [failed, setFailed] = useState(false);
const exportImage = () => {
setExporting(true);
setFailed(false);
onExport().then(
() => setExporting(false),
() => {
setExporting(false);
setFailed(true);
},
);
};
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>
))}
<button type="button" style={{ ...item, cursor: exporting ? 'progress' : 'pointer' }} title={t('docu.exportHint')} disabled={exporting} onClick={exportImage}>
<img src={exportIcon} alt="" width={40} height={40} draggable={false} />
{t('docu.export')}
</button>
{failed ? (
<p role="alert" style={{ margin: 0, fontSize: 12, color: 'var(--danger)' }}>
{t('docu.saveFailed')}
</p>
) : null}
{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>
);
}