Files
feuerwerk/src/editor/clipboard.ts
T
adminandClaude Opus 5.5 bc688f3efc
game-maker / gate (push) Successful in 3m50s
game-maker / suite (push) Successful in 12m3s
Refactoring round 7: split BehaviorSystem, fewer copies (#85)
Recurring cleanup round over the code since round 6 (056a622), 105
commits. Behaviour is unchanged. Duplicated code in src went from 29
clones with 229 lines (0.32 %) to 14 clones with 109 lines (0.15 %),
measured with jscpd and the same settings as round 6.

- BehaviorSystem.ts shrinks from 941 to 589 lines. Collecting the
  pulses (keys, timers, collisions, animations) moved to
  behaviorPulses.ts. The NodeContext the nodes receive moved to
  behaviorContext.ts. Imports from outside stay the same, TimeScaled
  is still exported from BehaviorSystem.ts.
- PointKindButton and drawPointGrip replace the "Point:" button with
  its menu and the point and handle drawing that the curve and the
  vector art tab each had on their own.
- lerpVec, cubicAt and splitCubic in src/core/util/vec.ts replace four
  copies of the cubic curve and two de Casteljau splits.
- mapNested in Properties.ts is the one walk over lists and structs
  that liveObjects and objectRefs did separately. withFieldRenamed
  and withoutField replace three copies of renaming a struct field
  and two of removing one.
- jsonBody and staleRevision build the JSON body and turn a 409 into
  StaleRevisionError for all stores. The auth store turns a 401 into
  its error in one place.
- Smaller helpers: structAssetFrom for the two "Make struct type"
  commands, shownObject in the inspector, selectedItem for the size
  gizmo and the polygon corners, solidTiles in the tilemap collision,
  requireVariablesAsset and requireBehaviorVariable.
- Left as is on purpose: the plain forwarding from the collision and
  physics systems to Solids, the pointer wiring of the canvases and
  the identical last lines of the two make commands.
- Removed unused code: tilemapTileIds and tilemapsUsing were never
  called since #128 and #131. EditorState.runtimeProject became unused
  with GameRun in #266. The assetId getter of the make commands was
  read only by a test.
- 60 values used only in their own file lost their export. The shader
  and particle node catalogues stay exported as in round 6.
- Five comments that told history now describe the current state. The
  doc comment of BehaviorSystem sits on the class again.
- README lists the struct, clip area and curve assets and mentions the
  game link from #266.

Findings:
- Deleting a tile that is painted on a tilemap leaves its cells in
  place, and they draw nothing. The delete dialog does not mention the
  tilemaps. That was probably the job of the unused tilemapsUsing.
  Left as is, because fixing it changes behaviour.
- The solid filter for tile surfaces was untested. A bent filter
  stayed green. A new test in tests/core/surface.test.ts now fails
  against it.

Tests:
- ./check.sh: 195 files, 2815 tests, exit 0 (one test removed with
  tilemapsUsing, one added for tile surfaces).
- Full e2e run: ALL PASSED, 1580 checks.
- Smokes jumprun, tetris, actor: 24, 19 and 21 checks, all passed.
  The example project files were restored byte for byte afterwards.
- Counter-checks, 12, all fail as expected: 10 in unit tests (among
  them mapNested, splitCubic, cubicAt, staleRevision, the stay pulse,
  the late timer and the time scale), 2 in the browser (Point button
  always enabled fails 47, selectedItem without a hit fails 29).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:16:37 +02:00

241 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Copy and paste of placed objects and graph pieces (#240). The clipboard is the editor's own: a
* copy names assets of this project, which mean nothing anywhere else.
*/
import { PasteGraphCommand, PasteObjectsCommand } from '../core/commands/pasteCommands';
import {
BEHAVIOR_PARAM,
findAnnotation,
findNode,
type GraphNode,
nodeDef,
RUN_BEHAVIOR,
} from '../core/graph';
import type { GraphAsset } from '../core/model/Asset';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
import { findObject } from '../core/model/hierarchy';
import { isLiveInstance } from '../core/model/instance';
import type { Vec2 } from '../core/model/Properties';
import { findGraphAsset, findObjectHolder, type ObjectHolder, type Project } from '../core/model/Project';
import { GRID_STEP } from './graph/graphView';
import { paletteEntries } from './graph/paletteEntries';
import { type EditorState, graphSelection, objectSelection } from './state';
import { SCENE_GRID_STEP } from './viewport/sceneViewHost';
/**
* `lineage` holds the originals and everything pasted from them since: selecting one of those and
* pasting puts the copy beside it, not inside it. `pastes` holds one id per paste; those still there
* stagger the next paste that lands without a pointer, so an undone paste frees its spot again.
*/
export interface EditorClipboard {
clip: Clip;
lineage: Set<string>;
pastes: string[];
}
function put(state: EditorState, clip: Clip, lineage: Iterable<string>, noun: string): true {
state.clipboard = { clip, lineage: new Set(lineage), pastes: [] };
state.setNotice(`Copied ${noun}`);
return true;
}
function counted(count: number, one: string): string {
return count === 1 ? `1 ${one}` : `${count} ${one}s`;
}
/** Copies the objects with everything below them; false when none of them is there. */
export function copyObjectIds(state: EditorState, holderId: string, ids: readonly string[]): boolean {
const holder = findObjectHolder(state.project, holderId);
const clip = holder && copyObjects(state.project, holder, ids);
if (!clip) return false;
const roots = clip.roots.map((r) => r.id);
return put(state, clip, roots, counted(roots.length, 'object'));
}
/** Copies the selected objects, or the selected nodes and boxes; false when nothing of that is selected. */
export function copySelection(state: EditorState): boolean {
const s = state.selection;
if (s?.kind === 'object') return copyObjectIds(state, s.holderId, s.objectIds);
if (s?.kind === 'node' || s?.kind === 'annotation') {
const graph = findGraphAsset(state.project, s.assetId)?.graph;
const nodeIds = s.kind === 'node' ? s.nodeIds : [];
const clip = graph && copyGraph(graph, nodeIds, s.annotationIds);
if (!clip) return false;
return put(state, clip, [], counted(clip.nodes.length + clip.annotations.length, 'item'));
}
return false;
}
function objectClip(state: EditorState): ObjectClip | null {
const clip = state.clipboard?.clip;
return clip?.kind === 'objects' ? clip : null;
}
function graphClip(state: EditorState): GraphClip | null {
const clip = state.clipboard?.clip;
return clip?.kind === 'graph' ? clip : null;
}
export function canPasteObjects(state: EditorState): boolean {
return objectClip(state) !== null;
}
export function canPasteGraph(state: EditorState): boolean {
return graphClip(state) !== null;
}
/** Whether an object may take children: stored in the holder, and not a container showing its template. */
function takesChildren(project: Project, holder: ObjectHolder, id: string): boolean {
const object = findObject(holder, id);
return object !== undefined && !isLiveInstance(project, holder, object);
}
/** The one selected object of `holderId`; empty when there are none or several. */
function pickedObject(state: EditorState, holderId: string): string {
const s = state.selection;
return s?.kind === 'object' && s.holderId === holderId && s.objectIds.length === 1 ? s.objectId : '';
}
/**
* Where each copied root goes. Into the `picked` object when there is one; beside it when that one
* is an original or an earlier paste; otherwise under the parent it had, if this holder has it,
* else on top.
*/
function pasteParents(
project: Project,
holder: ObjectHolder,
clip: ObjectClip,
lineage: ReadonlySet<string>,
picked: string,
): Record<string, string> {
let into: string | null = null;
if (picked && lineage.has(picked)) {
const parentId = findObject(holder, picked)?.parentId ?? '';
into = parentId && takesChildren(project, holder, parentId) ? parentId : '';
} else if (picked && takesChildren(project, holder, picked)) into = picked;
const parents: Record<string, string> = {};
for (const root of clip.roots) {
const own = clip.objects.find((o) => o.id === root.id)?.parentId ?? '';
parents[root.id] = into ?? (own && takesChildren(project, holder, own) ? own : '');
}
return parents;
}
/**
* Pastes the copied objects into `holderId`, into or beside `picked` – by default the one selected
* object. With `pointer` (world units) the copy is centred on it, otherwise it lands a step beside
* the original, a step further for every earlier paste still there.
*/
export function pasteObjects(
state: EditorState,
holderId: string,
pointer: Vec2 | null,
picked = pickedObject(state, holderId),
): boolean {
const board = state.clipboard;
const clip = objectClip(state);
const holder = findObjectHolder(state.project, holderId);
// Nothing of the right kind lets the key through: Ctrl+V then imports an image, as it always did.
if (!board || !clip || !holder) return false;
const centred =
pointer &&
centredOn(
pointer,
clip.roots.map((r) => ({ x: r.world.x, y: r.world.y, w: 0, h: 0 })),
);
const shift = centred
? { x: Math.round(centred.x), y: Math.round(centred.y) }
: stepped(board.pastes.filter((id) => findObject(holder, id)).length, SCENE_GRID_STEP);
const parents = pasteParents(state.project, holder, clip, board.lineage, picked);
const command = new PasteObjectsCommand(holderId, clip, parents, shift);
if (!state.execute(command)) return false;
board.pastes.push(command.rootIds[0]!);
for (const id of command.rootIds) board.lineage.add(id);
state.select(objectSelection(holderId, command.rootIds));
return true;
}
/** Whether `asset`'s graph may hold `node`: the palette would offer its kind there. */
function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolean {
return paletteEntries(project, asset).some(
(entry) =>
entry.def.type === node.type &&
(node.type !== RUN_BEHAVIOR || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]),
);
}
/** The part of a graph copy `asset` can take, and the nodes it cannot. */
function fittingPart(
project: Project,
asset: GraphAsset,
clip: GraphClip,
): { clip: GraphClip; skipped: GraphNode[] } {
const nodes = clip.nodes.filter((n) => fitsGraph(project, asset, n));
const kept = new Set(nodes.map((n) => n.id));
return {
clip: {
...clip,
nodes,
edges: clip.edges.filter((e) => kept.has(e.from.node) && kept.has(e.to.node)),
},
skipped: clip.nodes.filter((n) => !kept.has(n.id)),
};
}
/**
* Pastes the copied nodes and boxes into `assetId`'s graph, at `pointer` (graph units) or a grid
* step beside the original. `snap` keeps a pointer paste on the grid. What the graph cannot take is
* left out and named.
*/
export function pasteGraph(
state: EditorState,
assetId: string,
pointer: Vec2 | null,
snap: boolean,
): boolean {
const board = state.clipboard;
const clip = graphClip(state);
const asset = findGraphAsset(state.project, assetId);
if (!board || !clip || !asset) return false;
const part = fittingPart(state.project, asset, clip);
const skipped = [...new Set(part.skipped.map((n) => nodeDef(n.type)?.label ?? n.type))].join(', ');
if (part.clip.nodes.length === 0 && part.clip.annotations.length === 0) {
state.setNotice(`Nothing of the copy fits into this graph (${skipped})`);
return false;
}
const standing = board.pastes.filter((id) => findNode(asset.graph, id) || findAnnotation(asset.graph, id));
let shift = stepped(standing.length, GRID_STEP);
if (pointer) {
shift = centredOn(pointer, [
...part.clip.nodes.map((n) => ({ x: n.x, y: n.y, w: 0, h: 0 })),
...part.clip.annotations.map((a) => a.annotation),
]);
if (snap) shift = { x: snapped(shift.x), y: snapped(shift.y) };
}
const command = new PasteGraphCommand(assetId, part.clip, shift);
if (!state.execute(command)) return false;
board.pastes.push(command.nodeIds[0] ?? command.annotationIds[0]!);
state.select(graphSelection(assetId, command.nodeIds, command.annotationIds));
if (skipped) state.setNotice(`Pasted without what this graph cannot take: ${skipped}`);
return true;
}
function snapped(value: number): number {
return Math.round(value / GRID_STEP) * GRID_STEP;
}
/** One step down and right per paste still standing, and one more for this one. */
function stepped(standing: number, step: number): Vec2 {
const by = step * (standing + 1);
return { x: by, y: by };
}
/** The shift that puts the middle of the boxes' bounds on `at`. */
function centredOn(at: Vec2, boxes: readonly { x: number; y: number; w: number; h: number }[]): Vec2 {
const minX = Math.min(...boxes.map((b) => b.x));
const minY = Math.min(...boxes.map((b) => b.y));
const maxX = Math.max(...boxes.map((b) => b.x + b.w));
const maxY = Math.max(...boxes.map((b) => b.y + b.h));
return { x: at.x - (minX + maxX) / 2, y: at.y - (minY + maxY) / 2 };
}