Give the three asset tabs one base, and move the viewports into the editor

src/viewport/ held three classes only src/editor/ ever used, and framing.ts sat
under graph/ although the scene view wants the same bounds sum. The graph, the
texture and the container tab repeated the same header, subscription and
disposal code with two silent divergences: the graph tab said "nothing shown"
with null instead of '', and the texture tab refell back to the first asset on
a selection change but not on a project event.

These arrive together because GraphCanvas ties them: it reads framing from its
new home, hands its fold marks to folding.ts, and its '' change is what
GraphEditor must agree with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:38 +02:00
1 parent a2f2f8465c
commit 11981b40d0
15 files changed
+408 -425

No files matched your search

+73 -1
View File
@@ -5,7 +5,15 @@ import { GraphInterpreter } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from '../../src/editor/graph/folding';
import {
buildFoldMarks,
foldedEdgeShown,
type FoldMarkEnd,
foldMarkAt,
foldMarkKey,
foldMarkLabel,
foldMarkRadius,
} from '../../src/editor/graph/folding';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
/** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */
@@ -145,3 +153,67 @@ describe('several hidden links at one port', () => {
expect(foldMarkLabel([])).toBe('');
});
});
/** The bundling that keeps two hidden links out of the same port from stacking on one pixel. */
describe('fold mark bundling', () => {
const end = (over: Partial<FoldMarkEnd> & { edgeId: string; key: string }): FoldMarkEnd => ({
other: 'far',
otherLabel: 'Far',
isOutput: true,
portX: 100,
portY: 50,
color: 1,
hot: false,
...over,
});
it('gives one port one mark, however many links it hides', () => {
const marks = buildFoldMarks(
[
end({ edgeId: 'e1', key: 'a', other: 'n1', otherLabel: 'Add' }),
end({ edgeId: 'e2', key: 'a', other: 'n2', otherLabel: 'Compare' }),
end({ edgeId: 'e3', key: 'b', other: 'n3', otherLabel: 'Move' }),
],
16,
);
expect(marks).toHaveLength(2);
expect(marks[0]!.edgeIds).toEqual(['e1', 'e2']);
expect(foldMarkLabel(marks[0]!.names)).toBe('Add +1');
expect(marks[1]!.edgeIds).toEqual(['e3']);
});
it('dedupes the far nodes, so two links to one node select it once', () => {
const marks = buildFoldMarks(
[end({ edgeId: 'e1', key: 'a', other: 'n1' }), end({ edgeId: 'e2', key: 'a', other: 'n1' })],
16,
);
expect(marks[0]!.others).toEqual(['n1']);
});
it('puts the mark a stub out of its port, on the side the port faces', () => {
const [out] = buildFoldMarks([end({ edgeId: 'e1', key: 'a', isOutput: true })], 16);
const [into] = buildFoldMarks([end({ edgeId: 'e2', key: 'b', isOutput: false })], 16);
expect(out!.x).toBe(116);
expect(into!.x).toBe(84);
});
it('lets a pulse anywhere in the bundle colour the whole mark', () => {
const marks = buildFoldMarks(
[
end({ edgeId: 'e1', key: 'a', color: 1, hot: false }),
end({ edgeId: 'e2', key: 'a', color: 9, hot: true }),
],
16,
);
expect(marks[0]!.hot).toBe(true);
expect(marks[0]!.color).toBe(9);
});
it('grows the dot for a bundle and finds it under a click', () => {
expect(foldMarkRadius(1, 5)).toBe(5);
expect(foldMarkRadius(2, 5)).toBe(7);
const marks = [{ edgeIds: ['e1', 'e2'], x: 100, y: 50 }];
expect(foldMarkAt(marks, { x: 108, y: 50 }, 5)).toBe(marks[0]);
expect(foldMarkAt(marks, { x: 120, y: 50 }, 5)).toBeNull();
});
});
+27 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { type Box, boxesInBand, frameBoxes } from '../../src/editor/graph/framing';
import { boundsOf, type Box, boxesInBand, frameBoxes } from '../../src/editor/framing';
const LIMITS = { min: 0.25, max: 2.5 };
@@ -83,3 +83,29 @@ describe('fit', () => {
expect(f).toEqual({ zoom: 1, panX: 100, panY: 50 });
});
});
describe('boundsOf', () => {
it('encloses every box', () => {
const boxes: Box[] = [
{ id: 'a', x: 10, y: 20, w: 100, h: 60 },
{ id: 'b', x: -40, y: 5, w: 20, h: 10 },
];
expect(boundsOf(boxes)).toEqual({ minX: -40, minY: 5, maxX: 110, maxY: 80 });
});
it('reports nothing for no boxes at all', () => {
expect(boundsOf([])).toBeNull();
});
/** The scene view seeds the origin so a template drawn far to one side cannot hide it. */
it('keeps a seed inside the result even when every box is far away', () => {
const far: Box[] = [{ id: 'a', x: 500, y: 500, w: 10, h: 10 }];
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
expect(boundsOf(far, origin)).toEqual({ minX: 0, minY: 0, maxX: 510, maxY: 510 });
});
it('still answers for no boxes when a seed is given', () => {
const origin = { minX: 0, minY: 0, maxX: 0, maxY: 0 };
expect(boundsOf([], origin)).toEqual(origin);
});
});