feat(editor): F4.3 the editor's frame and surface; a switch's cloud read and written

packages/editor (React 18, Vite) draws a document as Java FILIUS does: Java's
default hardware icons, the device button of JSidebarButton, cables as
JCablePanel's quadratic curve between the buttons' middles, wireless cables
dashed, documentation items below. The surface moves with mouse and finger
(the pointer captured only once it moved) and zooms with Ctrl and the
wheel, a trackpad pinch, two fingers and the zoom bar; the first view shows
all. German and English catalogs with Java's device names, the dashboard's
colour tokens, the bridge with its origin check. packages/doc gets the
drawing's light view of a document.

Java's Switch.cloud (the Internet in the examples) was lost on reading; it
is now read, kept in the model and written, with FlsOracle and the corpus,
written and exported checks renewed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:05:25 +00:00
co-authored by Claude Opus 5.5
parent 0fd42f10e2
commit 63273916b5
49 changed files with 2442 additions and 8 deletions
+237
View File
@@ -0,0 +1,237 @@
// The editor's surface in Chromium (Playwright), on its built page.
//
// - Every FILIUS example drawn: its devices and cables, each device as large
// as Java made its button when it saved the file — the bounds in the files
// saved by Java 2.5 and later (before, the button's width left the label
// out, and older ones had other borders); the height within the font
// heights of the saving machines (15 to 17), the width where the label is
// wider than the icon within 16 % (the machines' fonts differ).
// - The gestures: the wheel with Ctrl zooms around the pointer, a drag moves
// the surface, a press without moving does nothing, the buttons step and fit.
// - English and dark.
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test, type Page } from '@playwright/test';
import { build, preview, type PreviewServer } from 'vite';
import { decodeJavaBeans, isJavaObject, listItems, readFlsArchive } from '@mdg-filius/fls';
import { ICONS, iconOf } from '../src/canvas/layout.js';
const here = dirname(fileURLToPath(import.meta.url));
const config = join(here, '../vite.config.ts');
const examples = join(here, '../../../corpus/java-examples');
let server: PreviewServer;
let base: string;
test.beforeAll(async () => {
await build({ configFile: config, logLevel: 'silent' });
server = await preview({ configFile: config, preview: { port: 0 }, logLevel: 'silent' });
base = server.resolvedUrls!.local[0]!;
});
test.afterAll(async () => {
await new Promise<void>((resolve) => server.httpServer.close(() => resolve()));
});
/** The width and height Java gave each device's button when it saved the file, in the file's order. */
function savedBounds(file: string): { width: number; height: number; javaClass: string; cloud: boolean }[] {
const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(join(examples, file)))).configurationXml);
const nodes = listItems(values[typeof values[0] === 'string' ? 1 : 0]);
return nodes.flatMap((item) => {
if (!isJavaObject(item)) return [];
const label = item.properties.get('imageLabel');
const device = item.properties.get('knoten');
const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined;
if (!isJavaObject(bounds) || !isJavaObject(device)) return [];
const field = (name: string, index: number) => (bounds.properties.get(name) ?? bounds.args[index]) as number;
return [{ width: field('width', 2), height: field('height', 3), javaClass: device.className.slice(device.className.lastIndexOf('.') + 1), cloud: device.properties.get('cloud') === true }];
});
}
/** The version line Java wrote at the file's start. */
function savedVersion(file: string): string | undefined {
const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(join(examples, file)))).configurationXml);
return typeof values[0] === 'string' ? values[0] : undefined;
}
async function open(page: Page, example: string, query = '') {
await page.goto(`${base}?example=${example}${query}`);
await page.waitForSelector('g.device');
}
const KIND_OF_CLASS: Record<string, 'pc' | 'notebook' | 'networkSwitch' | 'router' | 'gateway' | 'remoteLink'> = {
Rechner: 'pc',
Notebook: 'notebook',
Switch: 'networkSwitch',
Vermittlungsrechner: 'router',
Gateway: 'gateway',
Modem: 'remoteLink',
};
test('draws every example with devices as large as Java made them', async ({ page }) => {
const deviations: string[] = [];
let compared = 0;
for (const file of readdirSync(examples).filter((name) => name.endsWith('.fls'))) {
await open(page, file);
const saved = savedBounds(file);
const version = /Filius version: (\d+)\.(\d+)/.exec(savedVersion(file) ?? '');
const comparable = version !== null && (Number(version[1]) > 2 || (Number(version[1]) === 2 && Number(version[2]) >= 5));
const devices = await page.locator('g.device').evaluateAll((elements) =>
elements.map((element) => ({ box: element.getAttribute('data-box')!.split(' ').map(Number), label: element.getAttribute('aria-label') })),
);
expect(devices.length, file).toBe(saved.length);
expect(await page.locator('path[data-link]').count(), file).toBeGreaterThan(0);
for (const [index, { box, label }] of devices.entries()) {
const java = saved[index]!;
if (!comparable) continue;
compared += 1;
const [, , width, height] = box as [number, number, number, number];
const icon = ICONS[iconOf(KIND_OF_CLASS[java.javaClass]!, java.cloud)];
// The height: icon plus 5 plus Java's font height on the machine that saved it (15 to 17).
const heightOk = height - java.height >= 0 && height - java.height <= 2;
// The width: exact where the icon is wider than the label; else the label's width, which depends on
// the saving machine's font (Java takes the system's look and feel): within 16 %.
const tolerance = java.width === icon.width + 21 && width === icon.width + 21 ? 0 : Math.max(4, java.width * 0.16);
if (!heightOk || Math.abs(width - java.width) > tolerance) deviations.push(`${file} #${index + 1} ${label}: Java ${java.width}×${java.height}, here ${width}×${height}`);
}
}
expect(compared).toBeGreaterThan(80);
expect(deviations).toEqual([]);
});
test('zooms around the pointer with Ctrl and the wheel, and moves with a drag', async ({ page }) => {
await open(page, 'internetwork_komplett.fls');
const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number);
const device = page.locator('g.device').first();
const before = (await device.boundingBox())!;
const start = await zoom();
// Zoom in at the device's middle: it stays where it is, and grows.
const point = { x: before.x + before.width / 2, y: before.y + before.height / 2 };
await page.mouse.move(point.x, point.y);
await page.keyboard.down('Control');
await page.mouse.wheel(0, -100);
await page.keyboard.up('Control');
await expect.poll(zoom).toBeGreaterThan(start);
const after = (await device.boundingBox())!;
expect(Math.abs(after.x + after.width / 2 - point.x)).toBeLessThan(1.5);
expect(Math.abs(after.y + after.height / 2 - point.y)).toBeLessThan(1.5);
expect(after.width).toBeGreaterThan(before.width);
// A press without moving leaves the view; a drag moves it as far as the pointer went.
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
const empty = { x: surface.x + 20, y: surface.y + surface.height - 20 };
await page.mouse.move(empty.x, empty.y);
await page.mouse.down();
await page.mouse.up();
expect((await device.boundingBox())!.x).toBeCloseTo(after.x, 1);
await page.mouse.move(empty.x, empty.y);
await page.mouse.down();
await page.mouse.move(empty.x + 60, empty.y - 40, { steps: 6 });
await page.mouse.up();
const moved = (await device.boundingBox())!;
expect(moved.x - after.x).toBeCloseTo(60, 0);
expect(moved.y - after.y).toBeCloseTo(-40, 0);
});
test("steps with the buttons, shows all, and keeps to Java's limits", async ({ page }) => {
await open(page, 'internetwork_komplett.fls');
const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number);
await page.getByRole('button', { name: 'Vergrößern' }).click();
expect(await zoom()).toBe(1.25);
for (let press = 0; press < 6; press += 1) await page.getByRole('button', { name: 'Vergrößern' }).click({ force: true });
expect(await zoom()).toBe(2);
await expect(page.getByRole('button', { name: 'Vergrößern' })).toBeDisabled();
await page.getByRole('button', { name: 'Alles zeigen' }).click();
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
for (const box of await page.locator('g.device').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON() as DOMRect))) {
expect(box.left).toBeGreaterThanOrEqual(surface.x);
expect(box.right).toBeLessThanOrEqual(surface.x + surface.width);
expect(box.top).toBeGreaterThanOrEqual(surface.y);
expect(box.bottom).toBeLessThanOrEqual(surface.y + surface.height);
}
for (let press = 0; press < 8; press += 1) await page.getByRole('button', { name: 'Verkleinern' }).click({ force: true });
expect(await zoom()).toBe(0.25);
});
test('speaks English and goes dark when asked', async ({ page }) => {
await open(page, 'nat.fls', '&lang=en&theme=dark');
await expect(page.getByRole('img', { name: 'Home Router: Heimrouter' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Zoom in' })).toBeVisible();
expect(await page.evaluate(() => document.documentElement.dataset.theme)).toBe('dark');
await expect(page.getByRole('img', { name: 'Switch / Wi-Fi: Internet' })).toBeVisible();
});
test('zooms with two fingers around their middle, and moves with one', async ({ browser }) => {
const context = await browser.newContext({ hasTouch: true, viewport: { width: 1024, height: 768 } });
const page = await context.newPage();
await open(page, 'internetwork_komplett.fls');
const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number);
const cdp = await context.newCDPSession(page);
const touch = (type: 'touchStart' | 'touchMove' | 'touchEnd', points: readonly [number, number][]) =>
cdp.send('Input.dispatchTouchEvent', { type, touchPoints: points.map(([x, y], id) => ({ x, y, id })) });
const device = page.locator('g.device').nth(4);
const before = (await device.boundingBox())!;
const centre = { x: before.x + before.width / 2, y: before.y + before.height / 2 };
const start = await zoom();
// Two fingers 40 px either side of the device, spread to 80.
await touch('touchStart', [
[centre.x - 40, centre.y],
[centre.x + 40, centre.y],
]);
for (const distance of [50, 60, 70, 80]) {
await touch('touchMove', [
[centre.x - distance, centre.y],
[centre.x + distance, centre.y],
]);
}
await touch('touchEnd', []);
expect(await zoom()).toBeCloseTo(start * 2 > 2 ? 2 : start * 2, 1);
const after = (await device.boundingBox())!;
expect(Math.abs(after.x + after.width / 2 - centre.x)).toBeLessThan(2);
expect(Math.abs(after.y + after.height / 2 - centre.y)).toBeLessThan(2);
// One finger moves the surface.
await touch('touchStart', [[500, 700]]);
for (const step of [10, 20, 30, 40, 50]) await touch('touchMove', [[500 + step, 700]]);
await touch('touchEnd', []);
expect((await device.boundingBox())!.x - after.x).toBeCloseTo(50, 0);
await context.close();
});
test('moves with the wheel alone, and takes a press that hardly moved for no drag', async ({ page }) => {
await open(page, 'internetwork_komplett.fls');
const zoom = () => page.locator('[data-zoom]').getAttribute('data-zoom').then(Number);
const device = page.locator('g.device').first();
const before = (await device.boundingBox())!;
const start = await zoom();
await page.mouse.move(before.x, before.y);
await page.mouse.wheel(30, 50);
await expect.poll(async () => (await device.boundingBox())!.y).toBeCloseTo(before.y - 50, 0);
expect((await device.boundingBox())!.x).toBeCloseTo(before.x - 30, 0);
expect(await zoom()).toBe(start);
// Two pixels are a press, not a drag (DRAG_THRESHOLD): the surface stays.
const placed = (await device.boundingBox())!;
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
await page.mouse.move(surface.x + 20, surface.y + surface.height - 20);
await page.mouse.down();
await page.mouse.move(surface.x + 22, surface.y + surface.height - 20);
await page.mouse.up();
expect((await device.boundingBox())!.x).toBe(placed.x);
});
test('shows all of a large example at first', async ({ page }) => {
await open(page, 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls');
await page.waitForTimeout(100);
const zoom = Number(await page.locator('[data-zoom]').getAttribute('data-zoom'));
expect(zoom).toBeLessThan(1);
const surface = (await page.locator('svg.filius-canvas').boundingBox())!;
for (const box of await page.locator('g.device').evaluateAll((elements) => elements.map((element) => element.getBoundingClientRect().toJSON() as DOMRect))) {
expect(box.left).toBeGreaterThanOrEqual(surface.x);
expect(box.right).toBeLessThanOrEqual(surface.x + surface.width);
expect(box.top).toBeGreaterThanOrEqual(surface.y);
expect(box.bottom).toBeLessThanOrEqual(surface.y + surface.height);
}
});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>Filius</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@mdg-filius/editor",
"version": "0.0.0",
"private": true,
"license": "GPL-3.0-only",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"@mdg-filius/core": "0.0.0",
"@mdg-filius/doc": "0.0.0",
"@mdg-filius/fls": "0.0.0",
"@mdg-filius/protocol": "0.0.0",
"@mdg-filius/sim": "0.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"yjs": "^13.6.33"
},
"devDependencies": {
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^5.2.0",
"vite": "^7.3.6"
}
}
+24
View File
@@ -0,0 +1,24 @@
// The editor on a document: for now the surface that draws it (F4.3); the
// tools, the device sheet and the simulation follow (F4.4–F4.8).
import { useEffect, useState } from 'react';
import type * as Y from 'yjs';
import { topologyView, type TopologyView } from '@mdg-filius/doc';
import { Canvas } from './canvas/Canvas.js';
/** The document's drawing, read anew after every change. */
export function useTopologyView(doc: Y.Doc): TopologyView {
const [view, setView] = useState(() => topologyView(doc));
useEffect(() => {
setView(topologyView(doc));
const update = () => setView(topologyView(doc));
doc.on('update', update);
return () => doc.off('update', update);
}, [doc]);
return view;
}
export function Editor({ doc }: { readonly doc: Y.Doc }) {
const view = useTopologyView(doc);
return <Canvas view={view} />;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,50 @@
// The bridge to the dashboard (plan §9; the pattern of Scratch's
// lib/bridge.js and its twin dashboard-bridge.js): the editor runs in an
// iframe on another host, and between the two there is only postMessage.
// This file holds names, a version and the origin check, nothing that could
// drift apart from its twin in the dashboard (lib/bridge.js, F6). Every
// message is checked against the expected origin, both ways.
export const BRIDGE_VERSION = 1;
/** Dashboard → editor. */
export const COMMANDS = {
/** `{ locale: 'de' | 'en' }` */
SET_LOCALE: 'setLocale',
/** `{ theme: 'light' | 'dark', tokens?: { [name]: value } }`: the dashboard's resolved colours. */
SET_THEME: 'setTheme',
} as const;
/** Editor → dashboard. */
export const EVENTS = {
/** The editor listens. */
READY: 'ready',
} as const;
export interface BridgeHandlers {
readonly onLocale: (locale: string) => void;
readonly onTheme: (theme: 'light' | 'dark', tokens: Readonly<Record<string, string>>) => void;
}
interface Window {
readonly parent: { postMessage(message: unknown, targetOrigin: string): void };
addEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void;
removeEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void;
}
/** Listens to the dashboard at `parentOrigin` and tells it the editor is ready; returns how to stop. */
export function startBridge(window: Window, parentOrigin: string, handlers: BridgeHandlers): () => void {
const listener = (event: { readonly origin: string; readonly data: unknown }) => {
if (event.origin !== parentOrigin) return;
const data = event.data as { v?: unknown; type?: unknown; locale?: unknown; theme?: unknown; tokens?: unknown } | null;
if (data === null || typeof data !== 'object' || data.v !== BRIDGE_VERSION) return;
if (data.type === COMMANDS.SET_LOCALE && typeof data.locale === 'string') handlers.onLocale(data.locale);
if (data.type === COMMANDS.SET_THEME && (data.theme === 'light' || data.theme === 'dark')) {
const tokens = data.tokens !== null && typeof data.tokens === 'object' ? (data.tokens as Record<string, unknown>) : {};
handlers.onTheme(data.theme, Object.fromEntries(Object.entries(tokens).filter((entry): entry is [string, string] => entry[0].startsWith('--') && typeof entry[1] === 'string')));
}
};
window.addEventListener('message', listener);
window.parent.postMessage({ v: BRIDGE_VERSION, type: EVENTS.READY }, parentOrigin);
return () => window.removeEventListener('message', listener);
}
+232
View File
@@ -0,0 +1,232 @@
// 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 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 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 { ZoomBar } from './ZoomBar.js';
/** How far a pointer moves before a press becomes a drag. */
const DRAG_THRESHOLD = 4;
/** The label's width in Java's font, measured once per text. */
function useTextWidth(): (text: string) => number {
const cache = useRef(new Map<string, number>());
const context = useMemo(() => document.createElement('canvas').getContext('2d'), []);
return useCallback(
(text: string) => {
const known = cache.current.get(text);
if (known !== undefined) return known;
if (context === null) return text.length * 7;
context.font = JAVA_LABEL_FONT;
const width = context.measureText(text).width;
cache.current.set(text, width);
return width;
},
[context],
);
}
const rgba = (color: DocumentationView['color']) =>
`rgba(${Math.round(color.red * 255)}, ${Math.round(color.green * 255)}, ${Math.round(color.blue * 255)}, ${color.alpha})`;
function Documentation({ item }: { readonly item: DocumentationView }) {
if (item.kind === 'rectangle') {
return <rect className="doc-rectangle" x={item.x} y={item.y} width={item.width} height={item.height} fill={rgba(item.color)} data-doc={item.id} />;
}
return (
<foreignObject x={item.x} y={item.y} width={item.width} height={item.height} data-doc={item.id}>
<div
style={{
color: rgba(item.color),
font: `${item.bold ? 'bold ' : ''}${item.size}px "${item.font}", Dialog, sans-serif`,
whiteSpace: 'pre-wrap',
overflow: 'hidden',
width: '100%',
height: '100%',
}}
>
{item.text}
</div>
</foreignObject>
);
}
function Device({ node, box, kindName }: { readonly node: NodeView; readonly box: DeviceBox; readonly kindName: string }) {
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}`}>
<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}
</text>
</g>
);
}
export interface CanvasProps {
readonly view: TopologyView;
}
export function Canvas({ view }: CanvasProps) {
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 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]);
useLayoutEffect(() => {
const element = surface.current;
if (element === null) return undefined;
const observer = new ResizeObserver(([entry]) => setSize({ width: entry!.contentRect.width, height: entry!.contentRect.height }));
observer.observe(element);
return () => observer.disconnect();
}, []);
// The first view shows all; after that the view is the user's.
useEffect(() => {
if (viewport === null && size.width > 0 && size.height > 0) setViewport(fitted(content, size));
}, [viewport, size, content]);
const current = viewport ?? { scale: 1, x: 0, y: 0 };
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.
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 onPointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
const point = local(event.clientX, event.clientY);
pointers.current.set(event.pointerId, point);
if (pointers.current.size === 2) {
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 };
}
};
const onPointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
if (!pointers.current.has(event.pointerId)) return;
const point = local(event.clientX, event.clientY);
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) {
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 });
});
gesture.current = { kind: 'pinch', spread: nextSpread, middle: nextMiddle };
}
};
const onPointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
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.
const [remaining] = [...pointers.current.values()];
gesture.current = remaining === undefined ? null : { kind: 'pan', last: remaining };
};
// The wheel: with Ctrl (and a trackpad's pinch, which comes as Ctrl and the wheel) it zooms, else it moves.
// Registered by hand: React's wheel listener is passive and could not keep the page from zooming.
useEffect(() => {
const element = surface.current;
if (element === null) return undefined;
const onWheel = (event: WheelEvent) => {
event.preventDefault();
const point = local(event.clientX, event.clientY);
setViewport((before) => {
const base = before ?? { scale: 1, x: 0, y: 0 };
if (event.ctrlKey || event.metaKey) return zoomedAt(base, base.scale * Math.exp(-event.deltaY * 0.01), point);
return shifted(base, { x: -event.deltaX, y: -event.deltaY });
});
};
element.addEventListener('wheel', onWheel, { passive: false });
return () => element.removeEventListener('wheel', onWheel);
}, [local]);
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%' }}>
<svg
ref={surface}
className="filius-canvas"
role="application"
aria-label={t('canvas.label')}
style={style}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<g className="surface" transform={`translate(${current.x} ${current.y}) scale(${current.scale})`}>
<g className="docs">
{view.docs.map((item) => (
<Documentation key={item.id} item={item} />
))}
</g>
<g className="cables" fill="none" strokeWidth={2}>
{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;
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>
<g className="devices">
{view.nodes.map((node) => (
<Device key={node.id} node={node} box={boxes.get(node.id)!} kindName={t(`device.${node.kind}`)} />
))}
</g>
</g>
</svg>
<ZoomBar
scale={current.scale}
onZoomIn={() => setViewport(zoomedAt(current, zoomIn(current.scale), centre))}
onZoomOut={() => setViewport(zoomedAt(current, zoomOut(current.scale), centre))}
onFit={() => setViewport(fitted(content, size))}
/>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
// The zoom's buttons: out, the scale, in, show all. Each a target of at least
// 44 px for a finger (plan §7.2).
import type { CSSProperties } from 'react';
import { useTranslate } from '../i18n/i18n.js';
import { MAX_ZOOM, MIN_ZOOM } from './viewport.js';
const bar: CSSProperties = {
position: 'absolute',
right: 12,
bottom: 12,
display: 'flex',
alignItems: 'center',
gap: 2,
padding: 2,
borderRadius: 8,
background: 'var(--surface-raised)',
border: '1px solid var(--border-default)',
boxShadow: '0 1px 3px color-mix(in srgb, var(--shadow-color) 20%, transparent)',
};
const button: CSSProperties = {
minWidth: 44,
height: 44,
border: 'none',
borderRadius: 6,
background: 'transparent',
color: 'var(--text-primary)',
font: 'inherit',
fontSize: 18,
cursor: 'pointer',
};
export function ZoomBar({ scale, onZoomIn, onZoomOut, onFit }: { readonly scale: number; readonly onZoomIn: () => void; readonly onZoomOut: () => void; readonly onFit: () => void }) {
const t = useTranslate();
return (
<div style={bar} role="toolbar">
<button type="button" style={button} onClick={onZoomOut} disabled={scale <= MIN_ZOOM + 0.001} aria-label={t('zoom.out')} title={t('zoom.out')}>
−
</button>
<span style={{ minWidth: 56, textAlign: 'center', fontSize: 14, color: 'var(--text-secondary)' }} data-zoom={scale}>
{t('zoom.level', { percent: Math.round(scale * 100) })}
</span>
<button type="button" style={button} onClick={onZoomIn} disabled={scale >= MAX_ZOOM - 0.001} aria-label={t('zoom.in')} title={t('zoom.in')}>
+
</button>
<button type="button" style={{ ...button, fontSize: 16 }} onClick={onFit} aria-label={t('zoom.fit')} title={t('zoom.fit')}>
⤢
</button>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Java FILIUS' own hardware icons (GPL, src/main/resources/gfx/hardware),
// so that teachers recognise their worksheets (plan §7.1).
import cloud from '../assets/hardware/cloud.png';
import gateway from '../assets/hardware/gateway.png';
import laptop from '../assets/hardware/laptop.png';
import modem from '../assets/hardware/modem.png';
import router from '../assets/hardware/router.png';
import server from '../assets/hardware/server.png';
import networkSwitch from '../assets/hardware/switch.png';
import type { IconName } from './layout.js';
export const ICON_URLS: Readonly<Record<IconName, string>> = { cloud, gateway, laptop, modem, router, server, switch: networkSwitch };
+111
View File
@@ -0,0 +1,111 @@
// Where Java draws a device and a cable (filius.gui.netzwerksicht), so that a
// project looks as on the school's PCs and worksheets fit.
//
// A device is a JSidebarButton: its icon above, its label below, centred. The
// position a file stores is the button's top left corner. Its width is the
// wider of label and icon plus 5 and twice the border of 8, its height the
// font's height plus the icon's plus 5 (JSidebarButton.getWidth/getHeight);
// the icon sits at the top. A cable runs between the middles of the two
// buttons — a little below the icons' middles — as a quadratic curve whose
// control point lies a quarter, or for a falling line three quarters, across
// the rectangle the two ends span (JCablePanel.paintComponent, Java's
// default icons; integer arithmetic as Java's).
import type { NodeKind } from '@mdg-filius/core';
import type { Point, Rect } from './viewport.js';
/**
* The label's font. Java takes the system's look and feel (Main: the system
* look and feel), so its labels are as large as the saving machine's: in the
* bounds of the files Java 2.5 and later saved, mostly a font 17 high whose
* widths are Arial's at 14 px ("Vermittlungsrechner" 126 wide in Java, 125
* here). Arial is what most of them ran on (Windows) and what the editor
* mostly runs on (Helvetica on an iPad has its widths).
*/
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;
const BORDER = 8;
/** Java's default icons (IconMode.DEFAULT) and their sizes. */
export const ICONS = {
server: { width: 64, height: 94 },
laptop: { width: 84, height: 80 },
switch: { width: 70, height: 52 },
cloud: { width: 70, height: 52 },
router: { width: 77, height: 58 },
gateway: { width: 77, height: 58 },
modem: { width: 80, height: 56 },
} as const;
export type IconName = keyof typeof ICONS;
export function iconOf(kind: NodeKind, cloud: boolean): IconName {
switch (kind) {
case 'pc':
return 'server';
case 'notebook':
return 'laptop';
case 'networkSwitch':
return cloud ? 'cloud' : 'switch';
case 'router':
return 'router';
case 'gateway':
return 'gateway';
case 'remoteLink':
return 'modem';
case 'unsupported':
// As the iPad app draws a device it does not know.
return 'cloud';
}
}
export interface DeviceBox extends Rect {
readonly icon: IconName;
readonly iconRect: Rect;
/** The label's baseline, centred at x. */
readonly label: Point;
/** Where cables meet the device. */
readonly center: Point;
}
/** A device's button, as JSidebarButton lays it out, with the label's width as the browser measures it. */
export function deviceBox(node: { readonly kind: NodeKind; readonly cloud: boolean; readonly x: number; readonly y: number }, labelWidth: number): DeviceBox {
const icon = iconOf(node.kind, node.cloud);
const size = ICONS[icon];
const width = Math.max(Math.round(labelWidth), size.width) + 5 + 2 * BORDER;
const height = JAVA_LABEL_FONT_HEIGHT + size.height + 5;
return {
x: node.x,
y: node.y,
width,
height,
icon,
iconRect: { x: node.x + (width - size.width) / 2, y: node.y, width: size.width, height: size.height },
label: { x: node.x + width / 2, y: node.y + size.height + JAVA_LABEL_ASCENT },
center: { x: Math.trunc(node.x + 0.5 * width), y: Math.trunc(node.y + 0.5 * height) },
};
}
/** JCablePanel's curve between two devices' middles, as an SVG path. */
export function cablePath(first: Point, second: Point): string {
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;
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}`;
}
/** The rectangle around all of them. */
export function bounds(rects: readonly Rect[]): Rect {
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
const left = Math.min(...rects.map((rect) => rect.x));
const top = Math.min(...rects.map((rect) => rect.y));
const right = Math.max(...rects.map((rect) => rect.x + rect.width));
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
return { x: left, y: top, width: right - left, height: bottom - top };
}
+99
View File
@@ -0,0 +1,99 @@
// The view onto the surface: how far it is zoomed and shifted — the
// arithmetic behind it, without a browser.
//
// Copied from the dashboard's Kanban pinboard (frontend/src/apps/kanban/
// zoom.js; the rights are ours, plan §7.1) and changed from a scrolled
// container to a transform, as an SVG surface wants it:
//
// screen pixel = surface pixel × scale + shift
//
// Surface pixels are Java's: what the document stores and the .fls file has.
// Screen pixels are what the browser reports for a pointer.
export const MIN_ZOOM = 0.25;
export const MAX_ZOOM = 2;
/** The steps of the two buttons: a fixed ladder, so that one gets back to 100 % without searching. */
export const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2] as const;
export interface Point {
readonly x: number;
readonly y: number;
}
export interface Size {
readonly width: number;
readonly height: number;
}
export interface Rect extends Point, Size {}
export interface Viewport {
readonly scale: number;
/** Where the surface's origin is on the screen. */
readonly x: number;
readonly y: number;
}
export const clampZoom = (zoom: number): number => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
/** The next step up, from any value in between. */
export function zoomIn(zoom: number): number {
return ZOOM_STEPS.find((step) => step > zoom + 0.001) ?? MAX_ZOOM;
}
/** The next step down. */
export function zoomOut(zoom: number): number {
const smaller = ZOOM_STEPS.filter((step) => step < zoom - 0.001);
return smaller.at(-1) ?? MIN_ZOOM;
}
export function toSurface(viewport: Viewport, point: Point): Point {
return { x: (point.x - viewport.x) / viewport.scale, y: (point.y - viewport.y) / viewport.scale };
}
export function toScreen(viewport: Viewport, point: Point): Point {
return { x: point.x * viewport.scale + viewport.x, y: point.y * viewport.scale + viewport.y };
}
/**
* Zoomed to a scale so that a point stays put — the pointer for the wheel,
* the middle between two fingers, the middle of the view for the buttons.
* Without it the content jumps away from where one zooms.
*/
export function zoomedAt(viewport: Viewport, scale: number, point: Point): Viewport {
const to = clampZoom(scale);
const surface = toSurface(viewport, point);
return { scale: to, x: point.x - surface.x * to, y: point.y - surface.y * to };
}
export function shifted(viewport: Viewport, by: Point): Viewport {
return { ...viewport, x: viewport.x + by.x, y: viewport.y + by.y };
}
/**
* The view in which the content fits, in the middle, with a margin. Never
* above 100 %: fitting means zooming out until all is in view; three devices
* on a large screen at 200 % is not what anyone means by it.
*/
export function fitted(content: Rect, view: Size, margin = 24): Viewport {
if (!(content.width > 0) || !(content.height > 0) || !(view.width > 2 * margin) || !(view.height > 2 * margin)) {
return { scale: 1, x: margin - (content.width > 0 ? content.x : 0), y: margin - (content.height > 0 ? content.y : 0) };
}
const scale = clampZoom(Math.min(1, (view.width - 2 * margin) / content.width, (view.height - 2 * margin) / content.height));
return {
scale,
x: (view.width - content.width * scale) / 2 - content.x * scale,
y: (view.height - content.height * scale) / 2 - content.y * scale,
};
}
/** The distance between two touches: the measure of the pinch. */
export function spread(first: Point, second: Point): number {
return Math.hypot(second.x - first.x, second.y - first.y);
}
/** The middle between two touches. */
export function middle(first: Point, second: Point): Point {
return { x: (first.x + second.x) / 2, y: (first.y + second.y) / 2 };
}
+18
View File
@@ -0,0 +1,18 @@
{
"canvas.label": "Netzwerk",
"device.cable": "Verbindung",
"device.gateway": "Heimrouter",
"device.networkSwitch": "Switch / WLAN",
"device.notebook": "Notebook",
"device.pc": "Rechner",
"device.remoteLink": "Modem",
"device.router": "Vermittlungsrechner",
"device.unsupported": "Unbekanntes Gerät",
"dev.example": "Beispiel",
"dev.openFile": "Datei öffnen …",
"dev.title": "Filius-Editor (Entwicklung)",
"zoom.fit": "Alles zeigen",
"zoom.in": "Vergrößern",
"zoom.level": "{percent} %",
"zoom.out": "Verkleinern"
}
+18
View File
@@ -0,0 +1,18 @@
{
"canvas.label": "Network",
"device.cable": "Connection",
"device.gateway": "Home Router",
"device.networkSwitch": "Switch / Wi-Fi",
"device.notebook": "Notebook",
"device.pc": "Computer",
"device.remoteLink": "Modem",
"device.router": "Router",
"device.unsupported": "Unknown device",
"dev.example": "Example",
"dev.openFile": "Open file …",
"dev.title": "Filius editor (development)",
"zoom.fit": "Show all",
"zoom.in": "Zoom in",
"zoom.level": "{percent} %",
"zoom.out": "Zoom out"
}
+30
View File
@@ -0,0 +1,30 @@
// The editor's visible texts, German and English (plan §7.5): English keys,
// the words in i18n/{de,en}.json — where Java FILIUS has a word for a thing,
// Java's (MessagesBundle), so that the terms on worksheets fit. The language
// comes from the dashboard over the bridge.
import { createContext, useContext, type ReactNode } from 'react';
import de from './de.json';
import en from './en.json';
export type Language = 'de' | 'en';
export const CATALOGS: Readonly<Record<Language, Readonly<Record<string, string>>>> = { de, en };
export type Translate = (key: string, values?: Readonly<Record<string, string | number>>) => string;
/** The text of a key in a language, its {placeholders} filled; the key itself where the catalog has none. */
export function translate(language: Language, key: string, values: Readonly<Record<string, string | number>> = {}): string {
const text = CATALOGS[language][key] ?? key;
return text.replace(/\{(\w+)\}/g, (whole, name: string) => (name in values ? String(values[name]) : whole));
}
const LanguageContext = createContext<Language>('de');
export function LanguageProvider({ language, children }: { readonly language: Language; readonly children: ReactNode }) {
return <LanguageContext.Provider value={language}>{children}</LanguageContext.Provider>;
}
export function useTranslate(): Translate {
const language = useContext(LanguageContext);
return (key, values) => translate(language, key, values);
}
+98
View File
@@ -0,0 +1,98 @@
// The editor's page. Embedded in the dashboard (F6) it gets its document and
// language over the bridge; on its own — for development and the browser
// tests — it opens one of FILIUS' examples or a file (`?example=<name>`
// opens that example at once, `?lang=en` and `?theme=dark` set the rest).
import { StrictMode, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import * as Y from 'yjs';
import { writeProject } from '@mdg-filius/doc';
import { openProjectArchive } from '@mdg-filius/fls';
import { startBridge } from './bridge/dashboardBridge.js';
import { Editor } from './Editor.js';
import { LanguageProvider, translate, type Language } from './i18n/i18n.js';
import './theme/tokens.css';
const EXAMPLES = import.meta.glob('../../../corpus/java-examples/*.fls', { query: '?url', import: 'default', eager: true }) as Record<string, string>;
const exampleUrl = (name: string) => Object.entries(EXAMPLES).find(([path]) => path.endsWith(`/${name}`))?.[1];
function docOf(bytes: Uint8Array): Y.Doc {
const doc = new Y.Doc();
writeProject(doc, openProjectArchive(bytes, () => crypto.randomUUID()));
return doc;
}
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);
const [example, setExample] = useState(params.get('example') ?? '');
useEffect(() => {
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
const parent = params.get('parent');
if (parent === null) return undefined;
return startBridge(window, parent, {
onLocale: (locale) => setLanguage(locale.startsWith('en') ? 'en' : 'de'),
onTheme: (theme, tokens) => {
document.documentElement.dataset.theme = theme;
for (const [name, value] of Object.entries(tokens)) document.documentElement.style.setProperty(name, value);
},
});
// The page's parameters are read once.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const url = exampleUrl(example);
if (url === undefined) return;
void fetch(url)
.then((response) => response.arrayBuffer())
.then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
}, [example]);
const t = (key: string) => translate(language, key);
return (
<LanguageProvider language={language}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<header style={{ display: 'flex', gap: 12, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-default)', background: 'var(--surface-card)' }}>
<strong>{t('dev.title')}</strong>
<label>
{t('dev.example')}{' '}
<select value={example} onChange={(event) => setExample(event.target.value)}>
<option value="" />
{Object.keys(EXAMPLES)
.map((path) => path.slice(path.lastIndexOf('/') + 1))
.sort()
.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
<label>
{t('dev.openFile')}{' '}
<input
type="file"
accept=".fls"
onChange={(event) => {
const file = event.target.files?.[0];
if (file !== undefined) void file.arrayBuffer().then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
}}
/>
</label>
</header>
<main style={{ flex: 1, minHeight: 0 }}>
{doc === null ? null : <Editor key={doc.guid} doc={doc} />}
</main>
</div>
</LanguageProvider>
);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+74
View File
@@ -0,0 +1,74 @@
/* The editor's colours: the dashboard's semantic tokens under the same names
* (frontend/src/index.css), light by default and dark when the dashboard says
* so over the bridge (data-theme on <html>) — never by the system's setting,
* which the dashboard has already resolved. The bridge may set any of them
* to the dashboard's own values. */
:root {
--surface-page: #f5f6f8;
--surface-card: #ffffff;
--surface-raised: #ffffff;
--surface-sunken: #f1f3f5;
--surface-hover: #eff1f4;
--surface-selected: #e8f0f6;
--text-primary: #1d2939;
--text-secondary: #667085;
--text-muted: #78839a;
--border-default: #dee2e6;
--border-subtle: #e9ebef;
--border-strong: #c7ced8;
--border-focus: #2c9c32;
--accent: #1a5984;
--accent-hover: #2980b9;
--accent-soft: #e8f0f6;
--accent-on-soft: #1a5984;
--text-on-accent: #ffffff;
--shadow-color: #101828;
--danger: #e74c3c;
--danger-soft: #f8d7da;
--danger-on-soft: #721c24;
/* The surface itself: Java's white design area and its dark grey cables (JCablePanel). */
--canvas-bg: #ffffff;
--canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(192 192 192);
--canvas-label: #000000;
}
:root[data-theme='dark'] {
--surface-page: #14171c;
--surface-card: #1b1f26;
--surface-raised: #232830;
--surface-sunken: #101318;
--surface-hover: #262c35;
--surface-selected: #1e3448;
--text-primary: #e6e9ef;
--text-secondary: #a7b0bf;
--text-muted: #7d8797;
--border-default: #333a45;
--border-subtle: #272d36;
--border-strong: #4a5361;
--border-focus: #3ecf4a;
--accent: #4a9fd5;
--accent-hover: #6cb6e4;
--accent-soft: #16344b;
--accent-on-soft: #8ec5e8;
--text-on-accent: #0d1b26;
--shadow-color: #000000;
--danger: #f2695a;
--danger-soft: #3a1a17;
--danger-on-soft: #ffa79b;
/* Java's icons are drawn for a light ground: the surface stays light, a shade darker. */
--canvas-bg: #e9ecf1;
--canvas-cable: rgb(64 64 64);
--canvas-cable-wireless: rgb(150 150 150);
--canvas-label: #000000;
}
html,
body,
#root {
margin: 0;
height: 100%;
background: var(--surface-page);
color: var(--text-primary);
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+53
View File
@@ -0,0 +1,53 @@
// The bridge to the dashboard: only the expected origin, only this version,
// and the editor says when it listens.
import { describe, expect, it } from 'vitest';
import { BRIDGE_VERSION, startBridge } from '../src/bridge/dashboardBridge.js';
function fakeWindow() {
const posted: [unknown, string][] = [];
const listeners = new Set<(event: { origin: string; data: unknown }) => void>();
return {
posted,
window: {
parent: { postMessage: (message: unknown, origin: string) => posted.push([message, origin]) },
addEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.add(listener),
removeEventListener: (_: 'message', listener: (event: { origin: string; data: unknown }) => void) => listeners.delete(listener),
},
send: (origin: string, data: unknown) => {
for (const listener of listeners) listener({ origin, data });
},
listening: () => listeners.size,
};
}
describe('the bridge to the dashboard', () => {
it('says it is ready, to the dashboard only', () => {
const fake = fakeWindow();
startBridge(fake.window, 'https://dashboard.example', { onLocale: () => {}, onTheme: () => {} });
expect(fake.posted).toEqual([[{ v: BRIDGE_VERSION, type: 'ready' }, 'https://dashboard.example']]);
});
it('takes language and colours from the dashboard, and nothing from elsewhere', () => {
const fake = fakeWindow();
const got: unknown[] = [];
const stop = startBridge(fake.window, 'https://dashboard.example', {
onLocale: (locale) => got.push(['locale', locale]),
onTheme: (theme, tokens) => got.push(['theme', theme, tokens]),
});
fake.send('https://evil.example', { v: 1, type: 'setLocale', locale: 'en' });
fake.send('https://dashboard.example', { v: 2, type: 'setLocale', locale: 'en' });
fake.send('https://dashboard.example', { v: 1, type: 'setLocale', locale: 7 });
fake.send('https://dashboard.example', { v: 1, type: 'setTheme', theme: 'pink' });
fake.send('https://dashboard.example', null);
expect(got).toEqual([]);
fake.send('https://dashboard.example', { v: 1, type: 'setLocale', locale: 'en-GB' });
fake.send('https://dashboard.example', { v: 1, type: 'setTheme', theme: 'dark', tokens: { '--accent': '#123456', color: 'red', '--bad': 3 } });
expect(got).toEqual([
['locale', 'en-GB'],
['theme', 'dark', { '--accent': '#123456' }],
]);
stop();
expect(fake.listening()).toBe(0);
});
});
+58
View File
@@ -0,0 +1,58 @@
// The catalogs: the same keys in both languages, placeholders filled, and —
// where Java FILIUS names a thing — Java's word in its bundle (M11).
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { CATALOGS, translate } from '../src/i18n/i18n.js';
const bundles = join(dirname(fileURLToPath(import.meta.url)), '../../../tools/java-oracle/.upstream/src/main/resources/filius/messages');
/** A Java .properties file: ISO-8859-1, \uXXXX escapes, `key = value`. */
function properties(file: string): Map<string, string> {
const text = new TextDecoder('latin1').decode(readFileSync(join(bundles, file)));
const entries = new Map<string, string>();
for (const line of text.split(/\r?\n/)) {
const match = /^([\w.]+)\s*=\s*(.*)$/.exec(line);
if (match !== null) entries.set(match[1]!, match[2]!.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))).trim());
}
return entries;
}
/** The editor's keys whose words are Java's, and the key of each in Java's bundle. */
const FROM_JAVA: Readonly<Record<string, string>> = {
'device.pc': 'hw_rechner_msg1',
'device.notebook': 'hw_notebook_msg1',
'device.networkSwitch': 'hw_switch_msg1',
'device.router': 'hw_vermittlungsrechner_msg1',
'device.gateway': 'hw_gateway_msg1',
'device.cable': 'hw_kabel_msg1',
};
describe('the catalogs', () => {
it('have the same keys in German and English, each with a text', () => {
expect(Object.keys(CATALOGS.en).sort()).toEqual(Object.keys(CATALOGS.de).sort());
for (const catalog of Object.values(CATALOGS)) for (const text of Object.values(catalog)) expect(text.trim()).not.toBe('');
});
it('fill placeholders, and show a key they lack', () => {
expect(translate('de', 'zoom.level', { percent: 75 })).toBe('75 %');
expect(translate('en', 'zoom.level')).toBe('{percent} %');
expect(translate('de', 'no.such.key')).toBe('no.such.key');
});
it('name every kind of device', () => {
for (const kind of ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink', 'unsupported']) expect(CATALOGS.de[`device.${kind}`]).toBeDefined();
// Java's Modem.TYPE, the same in every language.
expect([CATALOGS.de['device.remoteLink'], CATALOGS.en['device.remoteLink']]).toEqual(['Modem', 'Modem']);
});
it.skipIf(!existsSync(bundles))("use Java's words where Java has them (M11)", () => {
const [de, en] = [properties('MessagesBundle_de_DE.properties'), properties('MessagesBundle_en_GB.properties')];
for (const [key, javaKey] of Object.entries(FROM_JAVA)) {
expect(CATALOGS.de[key], key).toBe(de.get(javaKey));
expect(CATALOGS.en[key], key).toBe(en.get(javaKey));
}
});
});
+58
View File
@@ -0,0 +1,58 @@
// Java's drawing of a device and a cable (JSidebarButton, JCablePanel).
import { describe, expect, it } from 'vitest';
import { bounds, cablePath, deviceBox, iconOf, ICONS, JAVA_LABEL_FONT_HEIGHT } from '../src/canvas/layout.js';
describe("Java's drawing", () => {
it('picks the icon of each kind, the cloud for a switch drawn as one', () => {
expect((['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink', 'unsupported'] as const).map((kind) => iconOf(kind, false))).toEqual([
'server',
'laptop',
'switch',
'router',
'gateway',
'modem',
'cloud',
]);
expect(iconOf('networkSwitch', true)).toBe('cloud');
expect(iconOf('router', true)).toBe('router');
});
it('makes a button as wide as its icon or its label, plus 5 and twice the border, and as high as both plus 5', () => {
const short = deviceBox({ kind: 'pc', cloud: false, x: 100, y: 200 }, 30);
expect(short).toMatchObject({ x: 100, y: 200, width: 64 + 21, height: JAVA_LABEL_FONT_HEIGHT + 94 + 5, icon: 'server' });
// The icon at the top, in the middle; the label's baseline under it, in the middle.
expect(short.iconRect).toEqual({ x: 100 + (85 - 64) / 2, y: 200, width: 64, height: 94 });
expect(short.label).toEqual({ x: 100 + 85 / 2, y: 200 + 94 + 14 });
const long = deviceBox({ kind: 'router', cloud: false, x: 0, y: 0 }, 124.6);
expect(long.width).toBe(125 + 21);
expect(long.iconRect.x).toBe((146 - 77) / 2);
});
it('meets cables in the middle of the button, in whole pixels as Java does', () => {
const box = deviceBox({ kind: 'notebook', cloud: false, x: 11, y: 7 }, 10);
// 84 + 21 = 105 wide, 17 + 80 + 5 = 102 high.
expect(box.center).toEqual({ x: Math.trunc(11 + 52.5), y: Math.trunc(7 + 51) });
});
it("draws a cable as JCablePanel's curve", () => {
// Rising to the right (y falls): the control point a quarter across the panel.
// Panel from (98, 48); control (98 + trunc((2 + 302) / 4), 48 + trunc((52 + 2) / 4)) = (174, 61).
expect(cablePath({ x: 100, y: 100 }, { x: 400, y: 50 })).toBe('M 100 100 Q 174 61 400 50');
// Falling to the right: three quarters across.
expect(cablePath({ x: 100, y: 50 }, { x: 400, y: 100 })).toBe(`M 100 50 Q ${98 + 3 * Math.trunc(304 / 4)} 61 400 100`);
// The same either way round.
expect(cablePath({ x: 400, y: 100 }, { x: 100, y: 50 })).toBe(`M 400 100 Q ${98 + 3 * Math.trunc(304 / 4)} 61 100 50`);
// Straight down: not falling (x does not change); the panel from (8, 8), control (8 + trunc(4 / 4), 8 + trunc(84 / 4)).
expect(cablePath({ x: 10, y: 10 }, { x: 10, y: 90 })).toBe('M 10 10 Q 9 29 10 90');
});
it('knows every icon it draws', () => {
expect(Object.keys(ICONS).sort()).toEqual(['cloud', 'gateway', 'laptop', 'modem', 'router', 'server', 'switch']);
});
it('spans the rectangles around everything', () => {
expect(bounds([])).toEqual({ x: 0, y: 0, width: 0, height: 0 });
expect(bounds([{ x: 10, y: 20, width: 5, height: 5 }, { x: -5, y: 30, width: 10, height: 10 }])).toEqual({ x: -5, y: 20, width: 20, height: 20 });
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node", "vite/client"],
"resolveJsonModule": true
},
"include": [".", "../browser", "../src"]
}
+59
View File
@@ -0,0 +1,59 @@
// The view onto the surface: zoom steps and limits, zooming around a point,
// fitting the content in.
import { describe, expect, it } from 'vitest';
import { clampZoom, fitted, MAX_ZOOM, middle, MIN_ZOOM, shifted, spread, toScreen, toSurface, zoomedAt, zoomIn, zoomOut, ZOOM_STEPS } from '../src/canvas/viewport.js';
describe('the view onto the surface', () => {
it('keeps the zoom between a quarter and twice', () => {
expect([clampZoom(0.1), clampZoom(1.3), clampZoom(9)]).toEqual([MIN_ZOOM, 1.3, MAX_ZOOM]);
});
it('steps along the ladder from any value, and back to where it was', () => {
expect(ZOOM_STEPS.map(zoomIn)).toEqual([0.5, 0.75, 1, 1.25, 1.5, 2, 2]);
expect(ZOOM_STEPS.map(zoomOut)).toEqual([0.25, 0.25, 0.5, 0.75, 1, 1.25, 1.5]);
expect([zoomIn(0.8), zoomOut(0.8), zoomIn(1.0005)]).toEqual([1, 0.75, 1.25]);
for (const step of ZOOM_STEPS.slice(1, -1)) expect(zoomOut(zoomIn(step))).toBe(step);
});
it('turns screen and surface pixels into each other', () => {
const view = { scale: 0.5, x: 100, y: -20 };
expect(toScreen(view, { x: 40, y: 80 })).toEqual({ x: 120, y: 20 });
expect(toSurface(view, toScreen(view, { x: 40, y: 80 }))).toEqual({ x: 40, y: 80 });
});
it('zooms so that the point zoomed at stays put, within the limits', () => {
const view = { scale: 1, x: 30, y: 40 };
const point = { x: 200, y: 150 };
const zoomed = zoomedAt(view, 1.5, point);
expect(zoomed.scale).toBe(1.5);
expect(toScreen(zoomed, toSurface(view, point))).toEqual(point);
expect(zoomedAt(view, 10, point).scale).toBe(MAX_ZOOM);
expect(toScreen(zoomedAt(view, 10, point), toSurface(view, point))).toEqual(point);
});
it('moves by what the pointer moved', () => {
expect(shifted({ scale: 2, x: 1, y: 2 }, { x: 10, y: -5 })).toEqual({ scale: 2, x: 11, y: -3 });
});
it('fits the content in the middle with a margin, never above 100 %', () => {
const content = { x: 100, y: 50, width: 800, height: 400 };
const view = fitted(content, { width: 448, height: 448 });
expect(view.scale).toBe(0.5);
// Left and right margin alike, the content in the middle vertically.
expect(toScreen(view, { x: 100, y: 50 })).toEqual({ x: 24, y: 124 });
expect(toScreen(view, { x: 900, y: 450 })).toEqual({ x: 424, y: 324 });
expect(fitted({ x: 0, y: 0, width: 100, height: 100 }, { width: 1000, height: 1000 }).scale).toBe(1);
expect(fitted({ x: 0, y: 0, width: 100_000, height: 10 }, { width: 500, height: 500 }).scale).toBe(MIN_ZOOM);
});
it('shows an empty surface at 100 % with its origin at the margin', () => {
expect(fitted({ x: 0, y: 0, width: 0, height: 0 }, { width: 500, height: 500 })).toEqual({ scale: 1, x: 24, y: 24 });
expect(fitted({ x: 10, y: 10, width: 50, height: 50 }, { width: 0, height: 0 })).toEqual({ scale: 1, x: 14, y: 14 });
});
it('measures two fingers', () => {
expect(spread({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(5);
expect(middle({ x: 0, y: 10 }, { x: 4, y: 20 })).toEqual({ x: 2, y: 15 });
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"declaration": false,
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"resolveJsonModule": true
},
"include": ["src", "vite.config.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vite';
// The editor's page. The corpus (FILIUS' examples, for the development page
// and the browser tests) lies outside the package.
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
base: './',
plugins: [react()],
server: { fs: { allow: [fileURLToPath(new URL('../..', import.meta.url))] } },
build: { outDir: 'dist', emptyOutDir: true },
});