feat(editor): F4.4b the design tools: palette, moving, cables, menus, undo

The palette in Java's order with Java's icons and names: a device dragged
onto the surface lands with its icon at the drop point, a tapped one in the
middle of the view; a new router asks for 2 to 8 interfaces as Java does.
A tap selects, a drag moves (divided by the scale), a long press or the
three-dot button opens Java's menu. The cable tool plugs by a drag or by
two taps, shows WAN or LAN on a home router's halves and Java's messages
when refused. Delete removes the selection; undo and redo by button and
Ctrl+Z/Ctrl+Y, each editor with its own origin. Browser tests with mouse
and touch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 09:37:49 +00:00
co-authored by Claude Opus 5.5
parent 27ab7a1f49
commit 806df6baca
16 changed files with 1218 additions and 74 deletions
+9 -2
View File
@@ -29,8 +29,8 @@ import {
import type { ImportedProject } from '@mdg-filius/fls';
import { emptyProject, inOrder, nodeEntry, ROOTS } from './projectDoc.js';
/** The kinds of the palette, in Java's order (GUIDesignSidebar). */
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'router', 'gateway', 'remoteLink'];
/** The kinds of the palette, in Java's order (GUIDesignSidebar.addComponentItemsToSidebar; the cable comes first, a tool). */
export const PALETTE: readonly Exclude<NodeKind, 'unsupported'>[] = ['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink'];
/** A router's interfaces, as Java's dialog offers them (2 to 8, 2 when it is closed). */
export const ROUTER_INTERFACES = { minimum: 2, maximum: 8, standard: 2 } as const;
@@ -141,6 +141,13 @@ export function removeLink(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => links(doc).delete(id), origin);
}
/** Java's "remove all cables" of a device (its context menu). */
export function removeLinksOf(doc: Y.Doc, id: Id, origin: unknown): void {
doc.transact(() => {
for (const [linkId, link] of [...links(doc).entries()]) if (link.get('sourceNode') === id || link.get('targetNode') === id) links(doc).delete(linkId);
}, origin);
}
/** A cable's end: a device, and the port when one was asked for. */
export interface CableEnd {
readonly node: Id;
+15 -1
View File
@@ -10,7 +10,7 @@ import { describe, expect, it } from 'vitest';
import * as Y from 'yjs';
import { interfaceKey, NetworkRuntimeEngine, type Id } from '@mdg-filius/core';
import { openProjectArchive, projectSnapshot, type ImportedProject } from '@mdg-filius/fls';
import { addDevice, connect, createUndoManager, docSnapshot, moveDevice, newDeviceProject, readProject, removeDevice, removeLink, repair, ROOTS, topologyView, type NewDevice } from '../src/index.js';
import { addDevice, connect, createUndoManager, docSnapshot, moveDevice, newDeviceProject, PALETTE, readProject, removeDevice, removeLink, removeLinksOf, repair, ROOTS, topologyView, type NewDevice } from '../src/index.js';
const fixture = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/new-devices.fls');
@@ -75,6 +75,10 @@ describe('a new device', () => {
}
});
it("comes from a palette in Java's order", () => {
expect(PALETTE).toEqual(['pc', 'notebook', 'networkSwitch', 'gateway', 'router', 'remoteLink']);
});
it('keeps a router between 2 and 8 interfaces, 2 when not asked', () => {
const count = (interfaces?: number) => newDeviceProject(device('router', interfaces === undefined ? {} : { interfaces })).graph.nodes[0]!.ports.length;
expect([count(), count(1), count(5), count(12)]).toEqual([2, 2, 5, 8]);
@@ -156,6 +160,16 @@ describe('a cable', () => {
expect(topologyView(doc).links[0]!.wireless).toBe(true);
});
it('is pulled with all of a device\'s cables, the device staying', () => {
const { doc, pcA, pcB, hub, router, home } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');
connect(doc, { node: pcB.id }, { node: hub.id }, 'link-2', 'me');
expect(connect(doc, { node: router.id }, { node: home.id }, 'link-3', 'me')).toEqual({ link: 'link-3' });
removeLinksOf(doc, hub.id, 'me');
expect(readProject(doc).graph.links.map((link) => link.id)).toEqual(['link-3']);
expect(readProject(doc).graph.nodes.some((node) => node.id === hub.id)).toBe(true);
});
it('is pulled alone, and with the device it is plugged into', () => {
const { doc, pcA, pcB, hub } = network();
connect(doc, { node: pcA.id }, { node: hub.id }, 'link-1', 'me');