A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
game-maker / suite (push) Successful in 6m13s
game-maker / suite (push) Successful in 6m13s
The graph could hold any number of nodes and said nothing about which of them belonged together. Groups and comments are two separate things, as decided in the issue: a group is a titled box that carries the nodes it encloses, a comment is free text that carries nothing. Both live as optional arrays on Graph, not as registry nodes. A comment that were a real node would run through the interpreter, show up in the drop palettes and be compiled into a shader — the wrong place for a piece of text. As arrays on Graph they exist in all four graph kinds at once, and a document without them means exactly "no groups", so no format bump. Membership is computed when the box is grabbed, never stored: the center of a node inside the box travels with it, and a node dragged out is simply out. A box inside a box travels with the outer one, its own nodes included. A group is grabbed by its title bar only — its body stays transparent to the pointer, or a rubber band could never be drawn across the nodes it holds. Deleting a box deletes the box; the nodes stay where they are, because a box that silently took ten nodes with it would be the most expensive surprise in the feature. The color is a free picker, and the title color follows from it: relative luminance per WCAG, threshold at 0.179 where the two contrast ratios meet — not 0.5, which would pick white on mid grey where black reads better. Resizing snaps the far edge to the grid, not the width, so the corner that is not being dragged stays put. The minimum size rises in whole steps for that reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat off-grid in exactly the mode meant to keep it on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1135205d69
commit
b9f1a9d319
21 files changed
+1473
-22
No files matched your search
@@ -2,6 +2,7 @@ import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||||
import { buildFrameSection, frameSignature, refreshFrameSection } from './frameSection';
|
||||
import type { InspectorHost } from './host';
|
||||
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
|
||||
@@ -88,6 +89,7 @@ export class Inspector extends Panel {
|
||||
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
|
||||
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
|
||||
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
|
||||
else if (s.kind === 'annotation') buildAnnotationSection(this.host, s.assetId, s.annotationId);
|
||||
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
|
||||
else buildObjectSection(this.host, s.holderId, s.objectId);
|
||||
this.scroll.setContent(this.host.body);
|
||||
@@ -99,6 +101,7 @@ export class Inspector extends Panel {
|
||||
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
|
||||
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
|
||||
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
|
||||
}
|
||||
|
||||
@@ -124,7 +127,9 @@ export class Inspector extends Panel {
|
||||
? refreshNodeSection(this.host, s.assetId, s.nodeId)
|
||||
: s.kind === 'frame'
|
||||
? refreshFrameSection(this.host, s.assetId, s.frameId)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
: s.kind === 'annotation'
|
||||
? refreshAnnotationSection(this.host, s.assetId, s.annotationId)
|
||||
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import {
|
||||
annotationNoun,
|
||||
RemoveAnnotationCommand,
|
||||
SetAnnotationColorCommand,
|
||||
SetAnnotationTextCommand,
|
||||
} from '../../core/commands/annotationCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { type AnnotationKind, findAnnotation, type GraphAnnotation } from '../../core/graph';
|
||||
import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import { type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Theme } from '../../ui';
|
||||
import { createFieldRow } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
|
||||
/** The key the colour field is registered under, so a live refresh finds it again. */
|
||||
const COLOR_KEY = 'annotation-color';
|
||||
const TEXT_KEY = 'annotation-text';
|
||||
|
||||
/**
|
||||
* Structure of a group/comment selection: its kind decides the wording, and whether it carries a
|
||||
* colour of its own decides whether the reset button is there.
|
||||
*/
|
||||
export function annotationSignature(project: Project, assetId: string, annotationId: string): string {
|
||||
const found = current(project, assetId, annotationId);
|
||||
if (!found) return 'missing';
|
||||
return `${found.kind}:${found.annotation.color === undefined ? 'default' : 'own'}`;
|
||||
}
|
||||
|
||||
/** Title (or text), colour, and the way to delete the box without touching what it frames. */
|
||||
export function buildAnnotationSection(host: InspectorHost, assetId: string, annotationId: string): void {
|
||||
const found = current(host.state.project, assetId, annotationId);
|
||||
if (!found) return;
|
||||
const { kind, annotation } = found;
|
||||
const noun = annotationNoun(kind);
|
||||
|
||||
addTitle(host, noun.toUpperCase());
|
||||
addRow(
|
||||
host,
|
||||
{ key: TEXT_KEY, label: kind === 'group' ? 'Title' : 'Text', kind: 'text', default: '' },
|
||||
annotation.text,
|
||||
(value) => new SetAnnotationTextCommand(assetId, kind, annotationId, textOf(value)),
|
||||
);
|
||||
addRow(
|
||||
host,
|
||||
{ key: COLOR_KEY, label: 'Color', kind: 'color', default: Theme.colors.annotation },
|
||||
annotation.color ?? Theme.colors.annotation,
|
||||
(value) => new SetAnnotationColorCommand(assetId, kind, annotationId, Number(value)),
|
||||
);
|
||||
addNote(
|
||||
host,
|
||||
'The text is drawn black or white, whichever reads better on this colour – so any colour stays legible.',
|
||||
'small',
|
||||
);
|
||||
if (annotation.color !== undefined)
|
||||
host.body.add(
|
||||
new Button('Use the default color', {
|
||||
onClick: () => host.state.execute(new SetAnnotationColorCommand(assetId, kind, annotationId, null)),
|
||||
}),
|
||||
);
|
||||
|
||||
addGap(host);
|
||||
addNote(host, describe(kind));
|
||||
addGap(host);
|
||||
host.body.add(
|
||||
new Button(`Delete ${noun}`, {
|
||||
variant: 'danger',
|
||||
onClick: () => host.state.execute(new RemoveAnnotationCommand(assetId, kind, annotationId)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Pushes the current text and colour into the rows. False when the box is gone (rebuild). */
|
||||
export function refreshAnnotationSection(
|
||||
host: InspectorHost,
|
||||
assetId: string,
|
||||
annotationId: string,
|
||||
): boolean {
|
||||
const found = current(host.state.project, assetId, annotationId);
|
||||
if (!found) return false;
|
||||
host.fields.get(TEXT_KEY)?.setValue(found.annotation.text);
|
||||
host.fields.get(COLOR_KEY)?.setValue(found.annotation.color ?? Theme.colors.annotation);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row, committed on change only: a colour dragged in the picker fires continuously, and an
|
||||
* undo entry per pixel of the hue bar would bury everything that came before it.
|
||||
*/
|
||||
function addRow(
|
||||
host: InspectorHost,
|
||||
def: PropertyDef,
|
||||
value: PropertyValue,
|
||||
commit: (v: PropertyValue) => Command,
|
||||
): void {
|
||||
const row = createFieldRow(host, def, value, {
|
||||
onInput: () => {},
|
||||
onChange: (next) => void host.state.execute(commit(next)),
|
||||
});
|
||||
host.fields.set(def.key, row);
|
||||
host.body.add(row.row);
|
||||
}
|
||||
|
||||
function describe(kind: AnnotationKind): string {
|
||||
return kind === 'group'
|
||||
? 'Nodes whose centre lies in the box move with it. Deleting the group leaves them where they are.'
|
||||
: 'A comment is read, not run: it frames nothing and moves alone.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Looked up inside the selected graph rather than by id alone, so a selection left over from a
|
||||
* deleted asset cannot edit whichever box later inherits the id.
|
||||
*/
|
||||
function current(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
annotationId: string,
|
||||
): { kind: AnnotationKind; annotation: GraphAnnotation } | null {
|
||||
const graph = findGraphAsset(project, assetId)?.graph;
|
||||
return graph ? findAnnotation(graph, annotationId) : null;
|
||||
}
|
||||
Reference in new issue
Block a user