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>
158 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|