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>
This commit is contained in:
2026-10-04 11:54:09 +00:00
co-authored by Claude Opus 5.5
parent de03f6bb96
commit 7e68414bb2
20 changed files with 1803 additions and 25 deletions
+18 -2
View File
@@ -41,6 +41,9 @@ import { ContextMenu, type MenuItem } from './design/ContextMenu.js';
import { DocumentationMenu } from './design/DocumentationMenu.js';
import { DocumentationSidebar } from './design/DocumentationSidebar.js';
import { DocumentationText } from './design/DocumentationText.js';
import { networkImage } from './design/exportImage.js';
import { exportNames } from './canvas/networkImage.js';
import { saveFile } from './download.js';
import { Palette, type PaletteKind } from './design/Palette.js';
import { RouterDialog } from './design/RouterDialog.js';
import { DeviceSheet } from './sheet/DeviceSheet.js';
@@ -132,7 +135,14 @@ const toFront = (open: readonly OpenDesktop[], id: string): readonly OpenDesktop
return open.some((each) => each.id === id && each.z === top) ? open : open.map((each) => (each.id === id ? { ...each, z: top + 1 } : each));
};
export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly createSimHost?: SimHostFactory }) {
export interface EditorProps {
readonly doc: Y.Doc;
readonly createSimHost?: SimHostFactory;
/** The project's name (its file's), for what is exported. */
readonly projectName?: string;
}
export function Editor({ doc, createSimHost, projectName }: EditorProps) {
const t = useTranslate();
const language = useLanguage();
const view = useTopologyView(doc);
@@ -281,6 +291,12 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
setSelection({ kind: 'doc', id });
};
/** „Exportieren“: the surface as Java's image, saved under the project's name. */
const exportImage = async () => {
const names = exportNames(projectName);
saveFile(await networkImage(view, names.footer), names.file);
};
const changeMode = (mode: EditorMode) => {
setDocumenting(mode === 'documentation');
if (mode === 'simulation') simulation.start(language);
@@ -347,7 +363,7 @@ export function Editor({ doc, createSimHost }: { readonly doc: Y.Doc; readonly c
return (
<div style={{ display: 'flex', height: '100%' }}>
{simulating ? null : documenting ? (
<DocumentationSidebar onItem={onDocumentationItem} />
<DocumentationSidebar onItem={onDocumentationItem} onExport={exportImage} />
) : (
<Palette
cableTool={cableTool}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+19 -7
View File
@@ -25,7 +25,7 @@ import type { Point, Rect } from './viewport.js';
export const JAVA_LABEL_FONT = '14px Arial, Helvetica, "Liberation Sans", "DejaVu Sans", sans-serif';
/** Its FontMetrics.getHeight, as most of the files' bounds have it. */
export const JAVA_LABEL_FONT_HEIGHT = 17;
const JAVA_LABEL_ASCENT = 14;
export const JAVA_LABEL_ASCENT = 14;
const BORDER = 8;
/** Java's default icons (IconMode.DEFAULT) and their sizes. */
@@ -88,16 +88,28 @@ export function deviceBox(node: { readonly kind: NodeKind; readonly cloud: boole
};
}
/** JCablePanel's curve between two devices' middles, as an SVG path. */
export function cablePath(first: Point, second: Point): string {
/** JCablePanel's bounds: the rectangle the two ends span, with 2 to spare (updateBounds). */
export function cableFrame(first: Point, second: Point): Rect {
const left = Math.min(first.x, second.x) - 2;
const top = Math.min(first.y, second.y) - 2;
return { x: left, y: top, width: Math.abs(first.x - second.x) + 4, height: Math.abs(first.y - second.y) + 4 };
}
/** JCablePanel's quadratic curve between two devices' middles: its control point. */
export function cableControl(first: Point, second: Point): Point {
const [x1, y1, x2, y2] = [first.x, first.y, second.x, second.y];
// The panel spans the two ends with 2 to spare (updateBounds); the control point is relative to it.
const left = Math.min(x1, x2) - 2;
const top = Math.min(y1, y2) - 2;
// The control point is relative to the panel's bounds.
const { x: left, y: top } = cableFrame(first, second);
let controlX = Math.trunc((x1 - left + x2 - left) / 4);
if ((x1 > x2 && y1 > y2) || (x1 < x2 && y1 < y2)) controlX = 3 * controlX;
const controlY = Math.trunc((y1 - top + y2 - top) / 4);
return `M ${x1} ${y1} Q ${left + controlX} ${top + controlY} ${x2} ${y2}`;
return { x: left + controlX, y: top + controlY };
}
/** JCablePanel's curve, as an SVG path. */
export function cablePath(first: Point, second: Point): string {
const control = cableControl(first, second);
return `M ${first.x} ${first.y} Q ${control.x} ${control.y} ${second.x} ${second.y}`;
}
/** The rectangle around all of them. */
+210
View File
@@ -0,0 +1,210 @@
// FILIUS' „Exportieren“ in the documentation mode (GUIContainer.createNetworkImage,
// GUIPrintPanel; measured with tools/java-oracle's ExportRun): the network
// and its documentation as a PNG, painted as the design shows them — the
// documentation below, an opaque rectangle a fifth of the way to white and
// a text without its border; cables over it, devices over the cables;
// nothing chosen — on a transparent background. The image is cut to what
// is there: 10 to spare on the left, above and on the right; 20 below the
// lowest element a light grey footer with the project's name, flush with
// the leftmost element, and 20 to spare below it. Java's own measure: a
// footer as wide as its words and 20 more, as high as its font.
//
// Not adopted from Java: an empty project gives Java an image of 20 × 35
// with nothing in it (its footer lands beyond reach, an integer overflow);
// here it is the footer alone. Java cuts at its surface's edges (nothing
// lies beyond them there); the editor has no edges, so nothing is cut. The
// footer is the project's name: a browser knows no file's path.
import type { DocumentationView, TopologyView } from '@mdg-filius/doc';
import { rectangleBorder, rectangleFill, textColor, fontFamily } from './documentationPaint.js';
import { bounds, cableControl, cableFrame, deviceBox, JAVA_LABEL_ASCENT, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type DeviceBox, type IconName } from './layout.js';
import type { Rect, Size } from './viewport.js';
/** GUIPrintPanel.EMPTY_BORDER: what is left around the elements. */
export const EXPORT_MARGIN = 10;
/** How far below the lowest element the footer begins, and what is left below it. */
export const FOOTER_GAP = 20;
/** GUIFooterPanel: Color.lightGray. */
export const FOOTER_COLOR = 'rgb(192, 192, 192)';
/** JCablePanel's colours: a cable, a wireless link (dashed). */
export const CABLE_COLOR = 'rgb(64, 64, 64)';
export const WIRELESS_COLOR = 'rgb(192, 192, 192)';
/** A device's label, as Java's look and feel writes it. */
export const LABEL_COLOR = 'rgb(0, 0, 0)';
export interface ExportLayout {
/** The part of the surface the image shows. */
readonly image: Rect;
/** The footer's label. */
readonly footer: Rect;
}
/** Where the image and its footer lie, around the elements' rectangles (GUIPrintPanel.getClipX … getClipHeight). */
export function exportLayout(parts: readonly Rect[], footer: Size): ExportLayout {
const content = parts.length === 0 ? null : bounds(parts);
const left = content?.x ?? 0;
const footerTop = content === null ? 0 : content.y + content.height + FOOTER_GAP;
const top = content?.y ?? footerTop;
// The footer's label already holds 20 more than its words; Java leaves 10 beyond its own clip of it.
const right = Math.max(content === null ? -Infinity : content.x + content.width, left + EXPORT_MARGIN + footer.width) + EXPORT_MARGIN;
const bottom = footerTop + footer.height + FOOTER_GAP;
return {
image: { x: left - EXPORT_MARGIN, y: top - EXPORT_MARGIN, width: right - (left - EXPORT_MARGIN), height: bottom - (top - EXPORT_MARGIN) },
footer: { x: left, y: footerTop, width: footer.width, height: footer.height },
};
}
/** The lines a text is written in within a width: its own lines, each broken after a space, a word wider than the width broken where it reaches it. */
export function wrapLines(text: string, width: number, measure: (text: string) => number): string[] {
const lines: string[] = [];
for (const paragraph of text.split('\n')) {
let line = '';
// Each word with the spaces after it: they stay with it, at a line's end too.
for (const word of paragraph.match(/\S*\s*/g)!.filter((each) => each !== '')) {
if (measure((line + word).trimEnd()) <= width) {
line += word;
continue;
}
if (line !== '') lines.push(line);
line = '';
for (const character of word) {
if (line !== '' && measure((line + character).trimEnd()) > width) {
lines.push(line);
line = '';
}
line += character;
}
}
lines.push(line);
}
return lines;
}
/** What of a 2D context the export paints with. */
export type Painter = Pick<
CanvasRenderingContext2D,
| 'save'
| 'restore'
| 'translate'
| 'beginPath'
| 'moveTo'
| 'quadraticCurveTo'
| 'roundRect'
| 'rect'
| 'clip'
| 'fill'
| 'stroke'
| 'fillText'
| 'measureText'
| 'drawImage'
| 'setLineDash'
| 'font'
| 'fillStyle'
| 'strokeStyle'
| 'lineWidth'
| 'textAlign'
| 'textBaseline'
>;
/** The devices' buttons and the rectangles of everything in the image (devices, cables, documentation), as Java's panels count them. */
export function networkParts(view: TopologyView, labelWidth: (text: string) => number): { readonly boxes: ReadonlyMap<string, DeviceBox>; readonly parts: readonly Rect[] } {
const boxes = new Map(view.nodes.map((node) => [node.id, deviceBox(node, labelWidth(node.label))]));
const parts: Rect[] = [...boxes.values()];
for (const link of view.links) {
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
if (source !== undefined && target !== undefined) parts.push(cableFrame(source.center, target.center));
}
for (const item of view.docs) parts.push({ x: item.x, y: item.y, width: item.width, height: item.height });
return { boxes, parts };
}
function paintDocumentation(context: Painter, item: DocumentationView) {
if (item.kind === 'rectangle') {
// As the surface draws it without the focus: inset by 1, its border 1 wide, corners of 5.
context.beginPath();
context.roundRect(item.x + 1, item.y + 1, Math.max(0, item.width - 2), Math.max(0, item.height - 2), 5);
context.fillStyle = rectangleFill(item.color, false);
context.fill();
context.lineWidth = 1;
context.strokeStyle = rectangleBorder(item.color);
context.stroke();
return;
}
context.save();
context.beginPath();
context.rect(item.x, item.y, item.width, item.height);
context.clip();
context.font = `${item.italic ? 'italic ' : ''}${item.bold ? 'bold ' : ''}${item.size}px ${fontFamily(item.font)}`;
context.fillStyle = textColor(item.color);
context.textAlign = 'left';
context.textBaseline = 'alphabetic';
// The surface's line height ("normal"): the font's own ascent and descent.
const metrics = context.measureText(item.text);
const ascent = metrics.fontBoundingBoxAscent;
const height = ascent + metrics.fontBoundingBoxDescent;
// Inside a padding of 1, as the surface writes it.
wrapLines(item.text, item.width - 2, (text) => context.measureText(text).width).forEach((line, index) => {
context.fillText(line, item.x + 1, item.y + 1 + ascent + index * height);
});
context.restore();
}
/** The devices' buttons and the image's layout, with the labels measured in Java's font on the context. */
export function measureNetworkImage(context: Painter, view: TopologyView, footerText: string): { readonly boxes: ReadonlyMap<string, DeviceBox>; readonly layout: ExportLayout } {
context.font = JAVA_LABEL_FONT;
const labelWidth = (text: string) => context.measureText(text).width;
const { boxes, parts } = networkParts(view, labelWidth);
return { boxes, layout: exportLayout(parts, { width: Math.round(labelWidth(footerText)) + 20, height: JAVA_LABEL_FONT_HEIGHT }) };
}
/**
* Paints the image onto a context as large as its layout's image: the
* documentation, the cables, the devices, the footer. The icons are Java's,
* loaded already.
*/
export function paintNetworkImage(context: Painter, view: TopologyView, icons: Readonly<Record<IconName, CanvasImageSource>>, footerText: string): ExportLayout {
const { boxes, layout } = measureNetworkImage(context, view, footerText);
context.save();
context.translate(-layout.image.x, -layout.image.y);
for (const item of view.docs) paintDocumentation(context, item);
context.lineWidth = 2;
for (const link of view.links) {
const [source, target] = [boxes.get(link.sourceNode), boxes.get(link.targetNode)];
if (source === undefined || target === undefined) continue;
const control = cableControl(source.center, target.center);
context.beginPath();
context.moveTo(source.center.x, source.center.y);
context.quadraticCurveTo(control.x, control.y, target.center.x, target.center.y);
context.strokeStyle = link.wireless ? WIRELESS_COLOR : CABLE_COLOR;
context.setLineDash(link.wireless ? [10] : []);
context.stroke();
}
context.setLineDash([]);
context.font = JAVA_LABEL_FONT;
context.textBaseline = 'alphabetic';
for (const node of view.nodes) {
const box = boxes.get(node.id)!;
context.drawImage(icons[box.icon], box.iconRect.x, box.iconRect.y, box.iconRect.width, box.iconRect.height);
context.fillStyle = LABEL_COLOR;
context.textAlign = 'center';
context.fillText(node.label, box.label.x, box.label.y);
}
// A JLabel's words: at its left, its font's height high.
context.fillStyle = FOOTER_COLOR;
context.textAlign = 'left';
context.fillText(footerText, layout.footer.x, layout.footer.y + JAVA_LABEL_ASCENT);
context.restore();
return layout;
}
/** The footer's words and the image's file name: the project's name and that name as a PNG, as Java names it after its file; without a name „Filius“. */
export function exportNames(projectName: string | undefined): { readonly footer: string; readonly file: string } {
const name = projectName?.trim() ?? '';
if (name === '') return { footer: 'Filius', file: 'Filius.png' };
const dot = name.lastIndexOf('.');
return { footer: name, file: `${dot > 0 ? name.slice(0, dot) : name}.png` };
}
@@ -2,6 +2,8 @@
// 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';
@@ -9,10 +11,13 @@ 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 }[] = [
@@ -39,12 +44,25 @@ const item: CSSProperties = {
touchAction: 'none',
};
export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) {
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 };
@@ -106,6 +124,15 @@ export function DocumentationSidebar({ onItem }: DocumentationSidebarProps) {
{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
+33
View File
@@ -0,0 +1,33 @@
// The image „Exportieren“ makes, in the browser: Java's icons loaded, a
// canvas as large as the image, painted (canvas/networkImage.ts), a PNG.
import type { TopologyView } from '@mdg-filius/doc';
import { ICON_URLS } from '../canvas/icons.js';
import type { IconName } from '../canvas/layout.js';
import { measureNetworkImage, paintNetworkImage } from '../canvas/networkImage.js';
async function loadIcons(): Promise<Record<IconName, HTMLImageElement>> {
const loaded = await Promise.all(
Object.entries(ICON_URLS).map(async ([name, url]) => {
const image = new Image();
image.src = url;
await image.decode();
return [name, image] as const;
}),
);
return Object.fromEntries(loaded) as Record<IconName, HTMLImageElement>;
}
/** The network and its documentation as a PNG, with the footer's words. */
export async function networkImage(view: TopologyView, footerText: string): Promise<Blob> {
const icons = await loadIcons();
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (context === null) throw new Error('no 2D context');
const { layout } = measureNetworkImage(context, view, footerText);
// Setting the size starts the context afresh: the painting measures again.
canvas.width = Math.ceil(layout.image.width);
canvas.height = Math.ceil(layout.image.height);
paintNetworkImage(context, view, icons, footerText);
return new Promise((resolve, reject) => canvas.toBlob((blob) => (blob === null ? reject(new Error('no image')) : resolve(blob)), 'image/png'));
}
+10
View File
@@ -0,0 +1,10 @@
// A file handed to the person: the browser saves it under the name given.
export function saveFile(blob: Blob, name: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = name;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
+3
View File
@@ -104,9 +104,12 @@
"dnsServer.tabNS": "Nameserver (NS)",
"dnsServer.tables": "Einträge",
"docu.color": "Farbe",
"docu.export": "Exportieren",
"docu.exportHint": "Als Bild exportieren",
"docu.fontSize": "Schriftgröße",
"docu.fontStyle": "Schriftstil",
"docu.rectangle": "Gliederungsfläche",
"docu.saveFailed": "Fehler beim Speichern aufgetreten!",
"docu.sidebar": "Dokumentation",
"docu.sizeLarge": "Groß",
"docu.sizeNormal": "Normal",
+3
View File
@@ -104,9 +104,12 @@
"dnsServer.tabNS": "Name server (NS)",
"dnsServer.tables": "Records",
"docu.color": "Colour",
"docu.export": "Export",
"docu.exportHint": "Export as Image",
"docu.fontSize": "Font Size",
"docu.fontStyle": "Font Style",
"docu.rectangle": "Structure Field",
"docu.saveFailed": "Error occurred!",
"docu.sidebar": "Documentation",
"docu.sizeLarge": "Large",
"docu.sizeNormal": "Normal",
+13 -3
View File
@@ -29,6 +29,8 @@ function App() {
const params = new URLSearchParams(window.location.search);
const [language, setLanguage] = useState<Language>(params.get('lang') === 'en' ? 'en' : 'de');
const [doc, setDoc] = useState<Y.Doc | null>(null);
/** The open file's name, for what the editor exports. */
const [fileName, setFileName] = useState<string | undefined>(undefined);
const [example, setExample] = useState(params.get('example') ?? '');
useEffect(() => {
@@ -51,13 +53,17 @@ function App() {
const doc = new Y.Doc();
writeProject(doc, emptyProject());
setDoc(doc);
setFileName(undefined);
return;
}
const url = exampleUrl(example);
if (url === undefined) return;
void fetch(url)
.then((response) => response.arrayBuffer())
.then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
.then((buffer) => {
setDoc(docOf(new Uint8Array(buffer)));
setFileName(example);
});
}, [example]);
// The development page lets its browser tests read the document's drawing and write into a file as another would (never the editor embedded in the dashboard).
@@ -102,13 +108,17 @@ function App() {
accept=".fls"
onChange={(event) => {
const file = event.target.files?.[0];
if (file !== undefined) void file.arrayBuffer().then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
if (file !== undefined)
void file.arrayBuffer().then((buffer) => {
setDoc(docOf(new Uint8Array(buffer)));
setFileName(file.name);
});
}}
/>
</label>
</header>
<main style={{ flex: 1, minHeight: 0 }}>
{doc === null ? null : <Editor key={doc.guid} doc={doc} />}
{doc === null ? null : <Editor key={doc.guid} doc={doc} {...(fileName === undefined ? {} : { projectName: fileName })} />}
</main>
</div>
</LanguageProvider>
@@ -14,6 +14,7 @@
import { memo, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react';
import type { TraceFrame } from '@mdg-filius/protocol';
import { saveFile } from '../download.js';
import { useLanguage, useTranslate } from '../i18n/i18n.js';
import { captureWords, exportText, frameCells, frameDetails, type CaptureWords } from './captureCells.js';
import type { Simulation } from './useSimulation.js';
@@ -111,14 +112,7 @@ export function CaptureWindow({ tabs, selected, simulation, x, y, z, onSelect, o
const choose = useMemo(() => (number: number) => onChoose(selected, number), [onChoose, selected]);
const download = () => {
const url = URL.createObjectURL(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }));
const link = document.createElement('a');
link.href = url;
link.download = `${tab?.title ?? title}.txt`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
};
const download = () => saveFile(new Blob([exportText(frames, clock, words)], { type: 'text/plain;charset=utf-8' }), `${tab?.title ?? title}.txt`);
return (
<section className="desktop-window capture-window" role="dialog" aria-label={title} data-capture="" style={{ left: x, top: y, zIndex: 5 + z }} onPointerDownCapture={onFront}>