Lists duplicate, rename and edit values right in their rows
game-maker / gate (push) Successful in 2m20s
game-maker / suite (push) Successful in 8m25s

Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.

- SortableList gets three optional callbacks. onDuplicate shows a
  copy button between the eye and the delete button. onRename opens
  a text field on double click, Enter or a click outside commits,
  Esc cancels, an empty name is ignored. value puts a NumberField
  into the row.
- Dragging on the number changes the value and never moves the row.
  A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
  Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
  frames, rename, opacity in percent. The Name and Opacity fields
  below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
  the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
  DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
  ("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
  because the model never imports from commands/.

Findings:

- Double clicking a shape that is not selected yet only selects it.
  The first click moves the list up, so the second click misses the
  name. Module 47 selects the row first.

Tests:

- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
  check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
  layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-22 06:02:47 +02:00
1 parent ae0247d86f
commit bbbf34109b
19 files changed
+747 -153

No files matched your search

+29
View File
@@ -29,6 +29,7 @@ import type { Project } from '../model/Project';
import { resolveSceneObjectProps } from '../properties/resolve';
import { localMatrixOf, transformOfObject } from '../properties/transform';
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
import { copyName } from '../util/names';
import { nextNumberedName } from './naming';
import { type Command, CommandError } from './Command';
import { requireTilemap, requireTilemapLayer } from './require';
@@ -175,6 +176,34 @@ export class AddTilemapLayerCommand implements Command {
}
}
/** A copy of a layer right above it, with everything painted on it, its z and whether it is shown. */
export class DuplicateTilemapLayerCommand implements Command {
readonly label = 'Duplicate layer';
readonly layerId = newId();
constructor(
private readonly assetId: string,
private readonly sourceId: string,
) {}
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
const index = asset.layers.findIndex((layer) => layer.id === this.sourceId);
const source = asset.layers[index];
if (!source) throw new CommandError(`Layer ${this.sourceId} not found`);
const name = copyName(
asset.layers.map((layer) => layer.name),
source.name,
);
asset.layers.splice(index + 1, 0, { ...source, id: this.layerId, name, cells: { ...source.cells } });
}
undo(project: Project): void {
const asset = requireTilemap(project, this.assetId);
asset.layers = removeById(asset.layers, this.layerId);
}
}
/** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */
export class RemoveTilemapLayerCommand implements Command {
readonly label = 'Remove layer';
+36
View File
@@ -15,6 +15,7 @@ import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
import { copyName } from '../util/names';
import { type Command, CommandError } from './Command';
import { nextNumberedName } from './naming';
import { requireVectorArt, requireVectorShape } from './require';
@@ -51,6 +52,41 @@ export class AddVectorShapeCommand implements Command {
}
}
/** A copy of a shape right above it, on the same spot, with its outlines, paints and opacity. */
export class DuplicateVectorShapeCommand implements Command {
readonly label = 'Duplicate shape';
readonly shapeId = newId();
constructor(
private readonly assetId: string,
private readonly sourceId: string,
) {}
execute(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
const index = asset.shapes.findIndex((shape) => shape.id === this.sourceId);
const source = asset.shapes[index];
if (!source) throw new CommandError(`Shape ${this.sourceId} not found`);
const copy: VectorShape = {
...source,
id: this.shapeId,
name: copyName(
asset.shapes.map((shape) => shape.name),
source.name,
),
rings: cloneRings(source.rings),
};
if (source.paint) copy.paint = clonePaint(source.paint);
if (source.stroke) copy.stroke = cloneStroke(source.stroke);
asset.shapes.splice(index + 1, 0, copy);
}
undo(project: Project): void {
const asset = requireVectorArt(project, this.assetId);
asset.shapes = removeById(asset.shapes, this.shapeId);
}
}
/** Takes a shape out; unlike a tilemap layer the last one may go – an empty drawing is a drawing. */
export class RemoveVectorShapeCommand implements Command {
readonly label = 'Remove shape';
+21
View File
@@ -6,6 +6,7 @@
* one that cannot do what is asked throws a `PixelEditError` with the reason in words.
*/
import type { PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
import { copyName } from '../util/names';
import { PIXEL_CANVAS_MAX } from './pixelCel';
export interface PixelArtState {
@@ -83,6 +84,26 @@ export function withLayerAdded(state: PixelArtState, id: string, aboveId = ''):
return next;
}
/**
* A copy of a layer right above it, named after it, with its cels on every frame. The cels are files
* named by their content, so the copy names the same files and costs no upload.
*/
export function withLayerDuplicated(state: PixelArtState, id: string, sourceId: string): PixelArtState {
const next = cloneState(state);
const index = layerIndex(next, sourceId);
const source = next.layers[index]!;
const name = copyName(
next.layers.map((layer) => layer.name),
source.name,
);
next.layers.splice(index + 1, 0, { ...source, id, name });
for (const frame of next.frames) {
const cel = frame.cels[sourceId];
if (cel) frame.cels[id] = cel;
}
return next;
}
/** Takes a layer out, with its cels on every frame. The last layer stays: there would be nothing to paint on. */
export function withLayerRemoved(state: PixelArtState, layerId: string): PixelArtState {
const next = cloneState(state);
+7
View File
@@ -0,0 +1,7 @@
/** "Sky copy", "Sky copy 2", … – the name of a duplicate, free among `taken`. */
export function copyName(taken: readonly string[], name: string): string {
const base = `${name} copy`;
const names = new Set(taken);
if (!names.has(base)) return base;
for (let i = 2; ; i++) if (!names.has(`${base} ${i}`)) return `${base} ${i}`;
}