diff --git a/README.md b/README.md index b0f9af53..1b49effb 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1266 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1272 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 66527f1f..711e4e04 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -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'; diff --git a/src/core/commands/vectorArtCommands.ts b/src/core/commands/vectorArtCommands.ts index 83b4d880..d034e494 100644 --- a/src/core/commands/vectorArtCommands.ts +++ b/src/core/commands/vectorArtCommands.ts @@ -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'; diff --git a/src/core/model/pixelEdit.ts b/src/core/model/pixelEdit.ts index 23df49e7..7e566ba9 100644 --- a/src/core/model/pixelEdit.ts +++ b/src/core/model/pixelEdit.ts @@ -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); diff --git a/src/core/util/names.ts b/src/core/util/names.ts new file mode 100644 index 00000000..be4e43e0 --- /dev/null +++ b/src/core/util/names.ts @@ -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}`; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 97742f30..042d841a 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -502,8 +502,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E addColor: side.addColorButton, removeColor: side.removeColorButton, createPalette: side.createPaletteButton, - layerName: side.layerName, - layerOpacity: side.layerOpacity, onion: side.onionBox, tiles: side.tilesBox, fps: side.fpsField, @@ -512,7 +510,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E resize: editor.pixelArtEditor.resizeButton, ...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])), ...Object.fromEntries(Object.entries(side.layerButtons).map(([k, b]) => [`layer:${k}`, b])), - ...Object.fromEntries(Object.entries(side.frameButtons).map(([k, b]) => [`frame:${k}`, b])), }; // The plus in a list's header is no widget of its own. const list = name === 'layer:add' ? side.layers : name === 'frame:add' ? side.frames : null; @@ -537,19 +534,29 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** * The centre of a layer's or frame's row, scrolled into view, with the centre of its picture; - * `eye` is the layer's switch, `remove` the cross (null on the last one). + * `eye` is the layer's switch, `duplicate` and `remove` the marks at the end (no cross on the + * last one), `name` where a double-click edits the name, `opacity` the field with its text and + * `renaming` the field a name is being edited in. */ layerRow: (layerId: string) => { const side = editor.pixelArtEditor.side; side.reveal(side.layers); const row = side.layers.rowGlobal(layerId); - return row + const opacity = side.layers.valueField(layerId); + const renaming = side.layers.renameField; + // On the name: the middle of the row may be the opacity field, which a press would scrub. + const name = side.layers.nameGlobal(layerId); + return row && name ? { - x: row.x + row.w / 2, - y: row.y + row.h / 2, + x: name.x, + y: name.y, thumb: side.layers.thumbGlobal(layerId), + name, eye: side.layers.eyeGlobal(layerId), + duplicate: side.layers.duplicateGlobal(layerId), remove: side.layers.removeGlobal(layerId), + opacity: opacity ? { ...centreOf(opacity), value: opacity.value } : null, + renaming: renaming ? { ...centreOf(renaming), value: renaming.value } : null, } : null; }, @@ -562,6 +569,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E x: row.x + row.w / 2, y: row.y + row.h / 2, thumb: side.frames.thumbGlobal(frameId), + duplicate: side.frames.duplicateGlobal(frameId), remove: side.frames.removeGlobal(frameId), } : null; @@ -662,6 +670,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null, /** The cross on a shape's row. */ shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null, + /** The duplicate mark on a shape's row, and where a double-click edits its name. */ + shapeDuplicateAt: (shapeId: string) => currentShapeList()?.duplicateGlobal(shapeId) ?? null, + shapeNameAt: (shapeId: string) => currentShapeList()?.nameGlobal(shapeId) ?? null, + /** The field a shape's name is being edited in, with what it holds; null while none is. */ + shapeRenaming: () => { + const field = currentShapeList()?.renameField; + return field ? { ...centreOf(field), value: field.value } : null; + }, /** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */ paintButton: () => centreOf(vectorArtEditor.paintButton), paintTarget: () => ({ @@ -784,15 +800,37 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** The plus in the layer list's header. */ addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null, - /** The name field of the painted layer and the Show dropdown, with what they hold (#138). */ + /** + * Where the painted layer's name stands in its row – a double-click there edits it (#138) – + * with the name the row shows and the field it is being edited in, while it is. + */ layerNameAt: () => { - const field = currentTilemapWidgets()?.layerName; + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + if (!widgets || !target) return null; + const layerId = paintedLayerId(state, target.asset); + const at = widgets.layers.nameGlobal(layerId); + const editing = widgets.layers.renameField; + return at + ? { + ...at, + value: findLayer(target.asset, layerId)?.name ?? '', + editing: editing ? { ...centreOf(editing), value: editing.value } : null, + } + : null; + }, + /** The Z field in the painted layer's row, with what it holds (#174). */ + layerZAt: () => { + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset)); return field ? { ...centreOf(field), value: field.value } : null; }, - /** The Z field of the painted layer, with what it holds (#174). */ - layerZAt: () => { - const field = currentTilemapWidgets()?.layerZ; - return field ? { ...centreOf(field), value: field.value } : null; + /** The duplicate mark on the painted layer's row. */ + duplicateLayerAt: () => { + const widgets = currentTilemapWidgets(); + const target = paintedTilemap(state); + return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null; }, focusAt: () => { const button = currentTilemapWidgets()?.focus; @@ -802,9 +840,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E layerRowAt: (index: number) => { const list = currentTilemapWidgets()?.layers; const id = list?.shownIds[index]; - const row = id ? list.rowGlobal(id) : null; - if (!list || !id || !row) return null; - const name = { x: row.x + row.w / 2, y: row.y + row.h / 2 }; + // On the name: the middle of the row may be the Z field, which a press would scrub. + const name = id ? list.nameGlobal(id) : null; + if (!list || !id || !name) return null; // `y` is the row's own line, so a check can tell whether it is inside the panel. return { id, y: name.y, name, visible: list.eyeGlobal(id) }; }, diff --git a/src/editor/inspector/shapeSection.ts b/src/editor/inspector/shapeSection.ts index ff0f8f03..2a737baa 100644 --- a/src/editor/inspector/shapeSection.ts +++ b/src/editor/inspector/shapeSection.ts @@ -11,6 +11,7 @@ import { AddVectorShapeCommand, CombineVectorShapesCommand, + DuplicateVectorShapeCommand, MoveVectorShapeCommand, RemoveVectorShapeCommand, RemoveVectorShapesCommand, @@ -387,12 +388,20 @@ function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: r asset.id, asset.shapes.map((shape) => shape.id), selectedIds, - (shapeId, toggle) => - toggle - ? host.state.toggleShape(asset.id, shapeId) - : host.state.select(shapeSelection(asset.id, [shapeId])), - (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)), - (shapeId) => removeShape(host, asset.id, shapeId), + { + onPick: (shapeId, toggle) => + toggle + ? host.state.toggleShape(asset.id, shapeId) + : host.state.select(shapeSelection(asset.id, [shapeId])), + onReorder: (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)), + onDuplicate: (shapeId) => { + const command = new DuplicateVectorShapeCommand(asset.id, shapeId); + if (host.state.execute(command)) host.state.select(shapeSelection(asset.id, [command.shapeId])); + }, + onRemove: (shapeId) => removeShape(host, asset.id, shapeId), + onRename: (shapeId, name) => + void host.state.execute(new RenameVectorShapeCommand(asset.id, shapeId, name)), + }, ); built = list; host.body.add(list); diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index 8a31275b..bb8b67fe 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -9,6 +9,7 @@ */ import { AddTilemapLayerCommand, + DuplicateTilemapLayerCommand, MoveTilemapLayerCommand, RemoveTilemapLayerCommand, RenameTilemapLayerCommand, @@ -25,11 +26,9 @@ import { Dropdown, HStack, Menu, - NumberField, Panel, ScrollContainer, SortableList, - TextField, Theme, ui, VStack, @@ -133,12 +132,11 @@ export interface TilemapPanelWidgets { tools: Map; /** Null while the project has no tile. */ palette: TilePalette | null; - /** The layers, with the plus that adds one and a cross on each that removes it. */ + /** + * The layers, with the plus that adds one. Each row names its layer (#138, a double-click edits the + * name), holds its `z` above the map's own (#174) and duplicates or removes it. + */ layers: SortableList; - /** The name of the layer painted on (#138). */ - layerName: TextField; - /** Its `z` above the map's own (#174). */ - layerZ: NumberField; /** How the other layers are shown in the scene view (#138). */ focus: Dropdown; } @@ -258,22 +256,6 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { const { state } = host; const paint = state.tilemapPaint; const painted = paintedLayerId(state, asset); - const paintedLayer = findLayer(asset, painted); - const paintedName = paintedLayer?.name ?? ''; - const layerName: TextField = new TextField(paintedName, { - onChange: (name) => { - // The command rejects a blank name but cannot put the old text back into the field, - // so an empty one is not sent at all. - if (name.trim()) state.execute(new RenameTilemapLayerCommand(asset.id, painted, name)); - else layerName.setValue(paintedName); - }, - }); - const layerZField = new NumberField(paintedLayer ? layerZ(paintedLayer) : 0, { - onChange: (z) => { - if (paintedLayer && z !== layerZ(paintedLayer)) - state.execute(new SetTilemapLayerZCommand(asset.id, painted, z)); - }, - }); const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', { onClick: () => new Menu( @@ -297,10 +279,23 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { new SetTilemapLayerHiddenCommand(asset.id, layerId, isLayerVisible(findLayer(asset, layerId)!)), ), onAdd: () => state.execute(new AddTilemapLayerCommand(asset.id)), + onDuplicate: (layerId) => { + const command = new DuplicateTilemapLayerCommand(asset.id, layerId); + if (state.execute(command)) state.setTilemapPaint({ layerId: command.layerId }); + }, onRemove: (layerId) => state.execute(new RemoveTilemapLayerCommand(asset.id, layerId)), + onRename: (layerId, name) => state.execute(new RenameTilemapLayerCommand(asset.id, layerId, name)), + value: { + get: (layerId) => { + const layer = findLayer(asset, layerId); + return layer ? layerZ(layer) : null; + }, + set: (layerId, z) => { + const layer = findLayer(asset, layerId); + if (layer && z !== layerZ(layer)) state.execute(new SetTilemapLayerZCommand(asset.id, layerId, z)); + }, + }, }), - layerName, - layerZ: layerZField, focus, }; built = widgets; @@ -342,16 +337,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { [painted], ); host.body.add(widgets.layers); - addNote(host, 'Drag a row to draw it earlier or later.', 'small'); - // Below the list rather than in it: a press on a row picks the layer, and a text field in its - // place would take the click that chooses it. - const nameRow = labelledRow('Name').row; - nameRow.add(layerName, 1); - host.body.add(nameRow); - const zRow = labelledRow('Z').row; - zRow.add(layerZField, 1); - host.body.add(zRow); - addNote(host, 'Added to the map’s own Z, as a child’s is.', 'small'); + addNote(host, 'Drag a row to reorder, double-click a name to rename.', 'small'); + addNote(host, 'The number is the layer’s Z, added to the map’s.', 'small'); const showRow = labelledRow('Show').row; showRow.add(focus, 1); host.body.add(showRow); diff --git a/src/editor/pixelart/PixelArtSide.ts b/src/editor/pixelart/PixelArtSide.ts index 20a075fd..58d52690 100644 --- a/src/editor/pixelart/PixelArtSide.ts +++ b/src/editor/pixelart/PixelArtSide.ts @@ -35,6 +35,7 @@ import { addFrame, addLayer, addPaletteColor, + duplicateLayer, mergeLayerDown, moveFrame, moveLayer, @@ -69,12 +70,11 @@ export class PixelArtSide extends ScrollContainer { readonly removeColorButton = new Button('Remove last'); readonly createPaletteButton = new Button('Create palette'); private readonly paletteNote = new Label('', { style: 'muted' }); + /** Each row names its layer (a double-click edits it), holds its opacity in percent and duplicates it. */ readonly layers: SortableList; - readonly layerName = new TextField(''); - readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 }); readonly layerButtons: Record<'merge', Button>; + /** Each row duplicates its frame right after it. */ readonly frames: SortableList; - readonly frameButtons: Record<'duplicate', Button>; private readonly thumbs = new PixelThumbs(); private readonly stopCels: () => void; readonly onionBox = new Checkbox(false); @@ -137,16 +137,25 @@ export class PixelArtSide extends ScrollContainer { this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)), onToggleHidden: (id) => this.toggleHidden(id), onAdd: () => this.addLayer(), + onDuplicate: (id) => this.duplicateLayer(id), onRemove: (id) => this.withAsset((a) => removeLayer(state, a, id)), + onRename: (id, name) => this.withAsset((a) => renameLayer(state, a, id, name)), + value: { + get: (id) => { + const layer = this.canvas.asset()?.layers.find((l) => l.id === id); + return layer ? Math.round(layer.opacity * 100) : null; + }, + preview: (id, percent) => this.previewOpacity(id, percent / 100), + set: (id, percent) => this.commitOpacity(id, percent / 100), + min: 0, + max: 100, + step: 1, + decimals: 0, + suffix: '%', + }, thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, id)), }); c.add(this.layers); - this.layerName.onChange = (name) => - this.withAsset((asset) => renameLayer(state, asset, this.canvas.layerId, name)); - this.layerOpacity.onNumberChange = (value) => - this.withAsset((asset) => setLayerOpacity(state, asset, this.canvas.layerId, value)); - c.add(row(new Label('Name', { style: 'muted' }), this.layerName)); - c.add(row(new Label('Opacity', { style: 'muted' }), this.layerOpacity)); this.layerButtons = { merge: new Button('Merge down', { onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)), @@ -160,15 +169,12 @@ export class PixelArtSide extends ScrollContainer { onPick: (id) => this.canvas.selectFrame(id), onMove: (from, to) => this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, to)), - onAdd: () => this.addFrame(false), + onAdd: () => this.addFrame(this.canvas.frameId, false), + onDuplicate: (id) => this.addFrame(id, true), onRemove: (id) => this.withAsset((a) => removeFrame(state, a, id)), thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)), }); c.add(this.frames); - this.frameButtons = { - duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }), - }; - c.add(row(this.frameButtons.duplicate)); c.add(heading('View')); this.onionBox.onChange = (on) => { @@ -296,6 +302,36 @@ export class PixelArtSide extends ScrollContainer { if (id) this.canvas.selectLayer(id); } + private duplicateLayer(layerId: string): void { + const asset = this.canvas.asset(); + if (!asset) return; + const id = duplicateLayer(this.state, asset, layerId); + if (id) this.canvas.selectLayer(id); + } + + /** The opacity a scrub started from, per layer – what the commit puts back first, as with a colour. */ + private readonly opacityBefore = new Map(); + + /** The opacity under the scrub straight into the layer, so the canvas shows it before it is an undo step. */ + private previewOpacity(layerId: string, opacity: number): void { + const layer = this.canvas.asset()?.layers.find((l) => l.id === layerId); + if (!layer) return; + if (!this.opacityBefore.has(layerId)) this.opacityBefore.set(layerId, layer.opacity); + layer.opacity = opacity; + this.state.preview(); + } + + private commitOpacity(layerId: string, opacity: number): void { + const asset = this.canvas.asset(); + const layer = asset?.layers.find((l) => l.id === layerId); + const before = this.opacityBefore.get(layerId); + this.opacityBefore.delete(layerId); + if (!asset || !layer) return; + if (before !== undefined) layer.opacity = before; + if (opacity !== layer.opacity) setLayerOpacity(this.state, asset, layerId, opacity); + else if (before !== undefined) this.state.preview(); + } + private toggleHidden(layerId: string): void { const asset = this.canvas.asset(); const layer = asset?.layers.find((l) => l.id === layerId); @@ -314,10 +350,11 @@ export class PixelArtSide extends ScrollContainer { if (frame) this.thumbs.paint(art, size, asset, paletteColors(this.state.project, asset), frame, layerId); } - private addFrame(copy: boolean): void { + /** A frame after `afterId`: empty, or a copy of it. */ + private addFrame(afterId: string, copy: boolean): void { const asset = this.canvas.asset(); if (!asset) return; - const id = addFrame(this.state, asset, this.canvas.frameId, copy); + const id = addFrame(this.state, asset, afterId, copy); if (id) this.canvas.selectFrame(id); } @@ -341,18 +378,10 @@ export class PixelArtSide extends ScrollContainer { const layerId = this.canvas.layerId; this.layers.setItems( - (asset?.layers ?? []).map((layer) => ({ - id: layer.id, - label: layer.name, - hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined, - hidden: layer.hidden, - })), + (asset?.layers ?? []).map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden })), layerId ? [layerId] : [], ); - const layer = asset?.layers.find((l) => l.id === layerId); - this.layerName.setValue(layer?.name ?? ''); - this.layerOpacity.setNumber(layer?.opacity ?? 1); - const index = asset && layer ? asset.layers.indexOf(layer) : -1; + const index = asset ? asset.layers.findIndex((l) => l.id === layerId) : -1; this.layerButtons.merge.setEnabled(index > 0); const frameId = this.canvas.frameId; @@ -360,8 +389,6 @@ export class PixelArtSide extends ScrollContainer { (asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })), frameId ? [frameId] : [], ); - const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1; - this.frameButtons.duplicate.setEnabled(frameIndex >= 0); this.relayoutContent(); } } diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index 7a91cbc3..86d98151 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -17,6 +17,7 @@ import { withFrameRemoved, withLayerAdded, withLayerChanged, + withLayerDuplicated, withLayerMergedDown, withLayerMoved, withLayerRemoved, @@ -52,6 +53,12 @@ export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: stri return edit(state, asset, 'Add layer', (s) => withLayerAdded(s, id, aboveId)) ? id : ''; } +/** A copy of the layer right above it; its id, so the tab can go on painting on the copy. */ +export function duplicateLayer(state: EditorState, asset: PixelArtAsset, layerId: string): string { + const id = newId(); + return edit(state, asset, 'Duplicate layer', (s) => withLayerDuplicated(s, id, layerId)) ? id : ''; +} + export function removeLayer(state: EditorState, asset: PixelArtAsset, layerId: string): boolean { return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId)); } diff --git a/src/editor/vectorart/ShapeList.ts b/src/editor/vectorart/ShapeList.ts index 81b754e7..fc56ea23 100644 --- a/src/editor/vectorart/ShapeList.ts +++ b/src/editor/vectorart/ShapeList.ts @@ -27,25 +27,32 @@ export class ShapeList extends SortableList { /** * `selectedIds` are the rows marked. `onPick` is told the row pressed and whether it was pressed with - * Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onRemove` is the - * cross on a row. There is no plus: a shape is drawn with the Pen, and "Add shape" stays below. + * Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onDuplicate` and + * `onRemove` are the marks at the end of a row, `onRename` a name edited in its row. There is no + * plus: a shape is drawn with the Pen, and "Add shape" stays below. */ constructor( private readonly state: EditorState, private readonly assetId: string, shapeIds: readonly string[], selectedIds: readonly string[], - onPick: (shapeId: string, toggle: boolean) => void, - onReorder: (from: number, to: number) => void, - onRemove: (shapeId: string) => void, + rows: { + onPick: (shapeId: string, toggle: boolean) => void; + onReorder: (from: number, to: number) => void; + onDuplicate: (shapeId: string) => void; + onRemove: (shapeId: string) => void; + onRename: (shapeId: string, name: string) => void; + }, ) { super({ title: 'Shapes', reversed: true, multiSelect: true, - onPick, - onMove: onReorder, - onRemove, + onPick: rows.onPick, + onMove: rows.onReorder, + onDuplicate: rows.onDuplicate, + onRemove: rows.onRemove, + onRename: rows.onRename, thumb: (id, art, size) => this.paintSwatch(id, art, size), }); const asset = this.asset(); diff --git a/src/ui/index.ts b/src/ui/index.ts index 22455629..7ac186b0 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -36,6 +36,7 @@ export { SortableList, type SortableItem, type SortableListOptions, + type SortableValue, type ThumbClip, } from './widgets/SortableList'; export { dropGap, dropMove, rowOrder } from './sortable'; diff --git a/src/ui/widgets/SortableList.ts b/src/ui/widgets/SortableList.ts index 701d67fe..73761c3c 100644 --- a/src/ui/widgets/SortableList.ts +++ b/src/ui/widgets/SortableList.ts @@ -1,26 +1,41 @@ /** * A list whose rows are dragged into another order – the layer list of every editor that has one: * the shapes of vector art, the layers and frames of pixel art, the layers of a tilemap. Each row may - * carry a small picture the owner paints, an eye that hides the item without removing it and a cross - * that removes it. Over the rows a header names the list and holds the plus that adds an item. + * carry a small picture the owner paints, a number typed or scrubbed in place, an eye that hides the + * item without removing it, a mark that duplicates it and a cross that removes it. A double-click on + * the name edits it in the row. Over the rows a header names the list and holds the plus that adds + * an item. * - * The list never changes its items itself. A drop, a cross or the plus reports to the owner, the - * owner runs its command, and the next `setItems` shows the result. + * The duplicate mark and the cross only show on the row under the pointer and on the marked ones, so + * a long list stays quiet; their places are kept free on every row, so nothing moves when they show. + * + * The list never changes its items itself. A drop, a cross, a name or a number reports to the owner, + * the owner runs its command, and the next `setItems` shows the result. */ import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js'; +import { pressCount } from '../clicks'; import { dropGap, dropMove, rowOrder } from '../sortable'; import { Theme, textStyle } from '../Theme'; +import { ui } from '../UIContext'; import { type Size, Widget } from '../Widget'; import { truncate } from './Label'; +import { NumberField } from './NumberField'; +import { TextField } from './TextField'; /** Height of one row: the picture plus the air a hovered row needs to look like a row. */ export const SORTABLE_ROW_HEIGHT = 30; const THUMB = 22; const PAD = 4; +/** Height of the fields that stand in a row: the name being edited and the number. */ +const FIELD_HEIGHT = 22; /** Width at the right end of a row that belongs to the eye. */ const EYE_SLOT = 26; +/** Width left of the cross that belongs to the duplicate mark. */ +const DUPLICATE_SLOT = 22; /** Width at the very right end of a row that belongs to the cross, and of the header to the plus. */ const REMOVE_SLOT = 24; +/** Width of the number field, where the rows have one. */ +const VALUE_WIDTH = 44; /** Height of the header, where there is one. */ const HEADER_HEIGHT = 24; /** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */ @@ -43,6 +58,22 @@ export interface ThumbClip { h: number; } +/** A number each row shows in a field of its own – a layer's opacity, its z. */ +export interface SortableValue { + /** Null leaves the row without a field. */ + get: (id: string) => number | null; + /** Once, when a scrub ends or a typed number is committed. */ + set: (id: string, value: number) => void; + /** While the field is scrubbed, for an owner that shows the number before it commits it. */ + preview?: (id: string, value: number) => void; + min?: number; + max?: number; + step?: number; + decimals?: number; + /** Small text right of the field, such as "%". */ + suffix?: string; +} + export interface SortableListOptions { /** The model's last item is the topmost row, as in every layer list: it is drawn last. */ reversed?: boolean; @@ -57,10 +88,15 @@ export interface SortableListOptions { onMove: (from: number, to: number) => void; /** Setting this gives every row an eye; a press on it reports the item and nothing else. */ onToggleHidden?: (id: string) => void; + /** Setting this gives every row a duplicate mark left of the cross; a press on it reports the item. */ + onDuplicate?: (id: string) => void; /** Setting this gives every row a cross at its right end; a press on it reports the item. */ onRemove?: (id: string) => void; /** No cross while a single item is left – a map or a picture needs one layer to paint on. */ keepOne?: boolean; + /** Setting this makes a double-click on a name edit it in the row; a blank or unchanged name is not reported. */ + onRename?: (id: string, name: string) => void; + value?: SortableValue; /** Stands in the header, left. */ title?: string; /** Setting this puts a plus at the right end of the header. */ @@ -75,7 +111,10 @@ interface Row { label: Text; hint: Text; eye: Graphics; + duplicate: Graphics; cross: Graphics; + /** Only with `value`; a child of the list, not of `box`, so it takes the pointer. */ + field: NumberField | null; } interface RowDrag { @@ -89,8 +128,21 @@ interface RowDrag { active: boolean; } -/** What the pointer is over: a row, a row's cross, or the plus. */ -type Hover = { row: number; cross: boolean } | 'add' | null; +/** Which part of a row a point is on. */ +type RowPart = 'row' | 'eye' | 'duplicate' | 'cross' | 'name'; + +/** What the pointer is over: a part of a row, or the plus. */ +type Hover = { row: number; part: RowPart } | 'add' | null; + +/** A name edited in its row: it goes away as soon as it loses the focus. */ +class RowNameField extends TextField { + onDone: () => void = () => {}; + + override onBlur(): void { + super.onBlur(); + this.onDone(); + } +} export class SortableList extends Widget { private readonly background = new Graphics(); @@ -103,6 +155,8 @@ export class SortableList extends Widget { private selected: readonly string[] = []; private over: Hover = null; private drag: RowDrag | null = null; + /** The name being edited in its row, and whose it is. */ + private renaming: { id: string; field: RowNameField } | null = null; constructor(private readonly options: SortableListOptions) { super(); @@ -138,8 +192,13 @@ export class SortableList extends Widget { this.shown = rowOrder(items, this.options.reversed ?? false); this.selected = selectedIds; while (this.rows.length < this.shown.length) this.rows.push(this.buildRow()); - while (this.rows.length > this.shown.length) this.rows.pop()!.box.destroy({ children: true }); + while (this.rows.length > this.shown.length) { + const row = this.rows.pop()!; + row.box.destroy({ children: true }); + row.field?.destroy(); + } if (countChanged) this.drag = null; + if (this.renaming && !this.shown.some((item) => item.id === this.renaming?.id)) ui().focus.blur(); this.invalidate(); } @@ -173,11 +232,25 @@ export class SortableList extends Widget { return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) }; } + /** A point on an item's name, where a double-click edits it (test hook); null without that row. */ + nameGlobal(id: string): { x: number; y: number } | null { + const row = this.rowGlobal(id); + if (!row) return null; + return { x: Math.round(row.x + this.textX + 12), y: Math.round(row.y + row.h / 2) }; + } + /** The centre of an item's eye (test hook); null without eyes or without that row. */ eyeGlobal(id: string): { x: number; y: number } | null { const row = this.rowGlobal(id); if (!row || !this.options.onToggleHidden) return null; - return { x: row.x + row.w - this.crossWidth - EYE_SLOT / 2, y: row.y + row.h / 2 }; + return { x: row.x + this.slots.eye - EYE_SLOT / 2, y: row.y + row.h / 2 }; + } + + /** The centre of an item's duplicate mark (test hook); null without marks or without that row. */ + duplicateGlobal(id: string): { x: number; y: number } | null { + const row = this.rowGlobal(id); + if (!row || !this.options.onDuplicate) return null; + return { x: row.x + this.slots.duplicate - DUPLICATE_SLOT / 2, y: row.y + row.h / 2 }; } /** The centre of an item's cross (test hook); null where the row has none. */ @@ -187,6 +260,18 @@ export class SortableList extends Widget { return { x: row.x + row.w - REMOVE_SLOT / 2, y: row.y + row.h / 2 }; } + /** The number field of an item's row (test hook); null where the row has none. */ + valueField(id: string): NumberField | null { + const index = this.shown.findIndex((item) => item.id === id); + const field = this.rows[index]?.field; + return field?.visible ? field : null; + } + + /** The field a name is being edited in (test hook); null while no name is. */ + get renameField(): TextField | null { + return this.renaming?.field ?? null; + } + /** The centre of the plus (test hook); null without one. */ addGlobal(): { x: number; y: number } | null { if (!this.options.onAdd) return null; @@ -203,9 +288,30 @@ export class SortableList extends Widget { return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1); } - /** Width the cross takes at the right end of every row, or none. */ - private get crossWidth(): number { - return this.options.onRemove ? REMOVE_SLOT : 0; + /** Where the name starts. */ + private get textX(): number { + return this.options.thumb ? PAD * 2 + THUMB : PAD * 2; + } + + /** + * The right edge of each part of a row, from the right: the cross, the duplicate mark, the eye and + * the number. A part a list does not have takes no room. + */ + private get slots(): { cross: number; duplicate: number; eye: number; value: number } { + const cross = this.w; + const duplicate = cross - (this.options.onRemove ? REMOVE_SLOT : 0); + const eye = duplicate - (this.options.onDuplicate ? DUPLICATE_SLOT : 0); + const value = eye - (this.options.onToggleHidden ? EYE_SLOT : 0); + return { cross, duplicate, eye, value }; + } + + /** Where the name has to end: before the number and its suffix, or before the eye. */ + private get nameRight(): number { + const { value } = this.slots; + const options = this.options.value; + if (!options) return value; + const suffix = options.suffix ? suffixWidth(options.suffix) + PAD : 0; + return value - PAD - suffix - VALUE_WIDTH; } private buildRow(): Row { @@ -215,12 +321,35 @@ export class SortableList extends Widget { const label = new Text({ text: '', style: textStyle('body') }); const hint = new Text({ text: '', style: textStyle('small') }); const eye = new Graphics(); + const duplicate = new Graphics(); const cross = new Graphics(); box.eventMode = 'none'; - box.addChild(art, clip, label, hint, eye, cross); + box.addChild(art, clip, label, hint, eye, duplicate, cross); art.mask = clip; this.addChildAt(box, this.getChildIndex(this.marker)); - return { box, art, clip, label, hint, eye, cross }; + const row: Row = { box, art, clip, label, hint, eye, duplicate, cross, field: null }; + const value = this.options.value; + if (value) { + const field = new NumberField(0, { + min: value.min, + max: value.max, + step: value.step, + decimals: value.decimals, + }); + // The row the field stands in now: a drop or a new item can give it another one. + const idOf = () => this.shown[this.rows.indexOf(row)]?.id; + field.onNumberInput = (n) => { + const id = idOf(); + if (id) value.preview?.(id, n); + }; + field.onNumberChange = (n) => { + const id = idOf(); + if (id) value.set(id, n); + }; + row.field = field; + this.addChildAt(field, this.getChildIndex(this.marker)); + } + return row; } private rowAt(y: number): number { @@ -233,7 +362,16 @@ export class SortableList extends Widget { return this.options.onAdd && local.x >= this.w - REMOVE_SLOT ? 'add' : null; const row = this.rowAt(local.y); if (row < 0) return null; - return { row, cross: this.canRemove && local.x >= this.w - REMOVE_SLOT }; + return { row, part: this.partAt(local.x) }; + } + + private partAt(x: number): RowPart { + const slots = this.slots; + if (this.canRemove && x >= slots.duplicate) return 'cross'; + if (this.options.onDuplicate && x >= slots.eye && x < slots.duplicate) return 'duplicate'; + if (this.options.onToggleHidden && x >= slots.value && x < slots.eye) return 'eye'; + if (this.options.onRename && x >= this.textX && x < this.nameRight) return 'name'; + return 'row'; } private onPress(e: FederatedPointerEvent): void { @@ -247,13 +385,22 @@ export class SortableList extends Widget { return; } const id = this.shown[hit.row]!.id; - if (hit.cross) { + if (hit.part === 'cross') { this.options.onRemove?.(id); return; } - const eyeRight = this.w - this.crossWidth; - if (this.options.onToggleHidden && local.x >= eyeRight - EYE_SLOT && local.x < eyeRight) { - this.options.onToggleHidden(id); + if (hit.part === 'duplicate') { + this.options.onDuplicate?.(id); + return; + } + if (hit.part === 'eye') { + this.options.onToggleHidden?.(id); + return; + } + // Keyed by list and item, not by this widget: the first click's pick may rebuild the list. + if (hit.part === 'name' && pressCount(e, `sortable:${this.options.title ?? ''}:${id}`) >= 2) { + this.drag = null; + this.startRename(hit.row); return; } if (this.options.multiSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) { @@ -266,6 +413,43 @@ export class SortableList extends Widget { this.drag = { from: hit.row, pick, startY: y, y, active: false }; } + private startRename(index: number): void { + const item = this.shown[index]; + const onRename = this.options.onRename; + if (!item || !onRename) return; + const field = new RowNameField(item.label); + field.onChange = (name) => { + const trimmed = name.trim(); + if (trimmed && trimmed !== item.label) onRename(item.id, trimmed); + }; + field.onDone = () => { + if (this.renaming?.field === field) this.renaming = null; + // Not while the focus manager is still busy with it. + queueMicrotask(() => { + if (!field.destroyed) field.destroy(); + }); + if (!this.destroyed) this.invalidate(); + }; + this.renaming = { id: item.id, field }; + this.addChild(field); + this.placeRenameField(); + ui().focus.focus(field); + field.selectAll(); + this.invalidate(); + } + + private placeRenameField(): void { + const renaming = this.renaming; + if (!renaming) return; + const index = this.shown.findIndex((item) => item.id === renaming.id); + const x = this.textX - PAD; + renaming.field.position.set( + x, + this.headerHeight + index * SORTABLE_ROW_HEIGHT + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2, + ); + renaming.field.resize(Math.max(40, this.nameRight - x - PAD), FIELD_HEIGHT); + } + private onMove(e: FederatedPointerEvent): void { const drag = this.drag; if (!drag) return; @@ -297,18 +481,22 @@ export class SortableList extends Widget { .fill(Theme.colors.control); this.marker.clear(); this.redrawHeader(); + const slots = this.slots; + const nameRight = this.nameRight; const eyes = Boolean(this.options.onToggleHidden); const crosses = this.canRemove; - const crossW = this.crossWidth; + const duplicates = Boolean(this.options.onDuplicate); + const value = this.options.value; const thumbs = this.options.thumb; - const textX = thumbs ? PAD * 2 + THUMB : PAD * 2; + const textX = this.textX; const hovered = this.over !== 'add' ? this.over : null; for (const [index, item] of this.shown.entries()) { const row = this.rows[index]!; const y = top + index * SORTABLE_ROW_HEIGHT; row.box.position.set(0, y); const selected = this.selected.includes(item.id); - if (selected || hovered?.row === index) + const underPointer = hovered?.row === index; + if (selected || underPointer) this.background .rect(0, y, this.w, SORTABLE_ROW_HEIGHT) .fill(selected ? Theme.colors.selection : Theme.colors.controlHover); @@ -324,30 +512,51 @@ export class SortableList extends Widget { } row.art.alpha = item.hidden ? 0.35 : 1; - const eyeW = eyes ? EYE_SLOT : 0; - row.hint.text = item.hint ?? ''; - const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0; + // The number, with its suffix right of it; without one, the owner's hint stands there. + const number = value?.get(item.id) ?? null; + row.hint.text = value ? (number !== null ? (value.suffix ?? '') : '') : (item.hint ?? ''); row.hint.position.set( - Math.round(this.w - crossW - eyeW - PAD - row.hint.width), + Math.round(slots.value - PAD - row.hint.width), (SORTABLE_ROW_HEIGHT - row.hint.height) / 2, ); + const hintW = !value && row.hint.text ? row.hint.width + PAD * 2 : 0; + if (row.field) { + row.field.visible = number !== null; + if (number !== null) row.field.setNumber(number); + const suffix = value?.suffix ? row.hint.width + PAD : 0; + row.field.position.set( + slots.value - PAD - suffix - VALUE_WIDTH, + y + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2, + ); + row.field.resize(VALUE_WIDTH, FIELD_HEIGHT); + } + + const renamed = this.renaming?.id === item.id; + row.label.visible = !renamed; row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted'); - row.label.text = truncate( - item.label, - row.label.style, - Math.max(0, this.w - textX - hintW - eyeW - crossW - PAD), - ); + row.label.text = truncate(item.label, row.label.style, Math.max(0, nameRight - textX - hintW - PAD)); row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2); + const mid = SORTABLE_ROW_HEIGHT / 2; row.eye.clear(); - if (eyes) drawEye(row.eye, this.w - crossW - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden); - row.cross.clear(); - if (crosses) { - const hot = hovered?.row === index && hovered.cross; - drawCross(row.cross, this.w - REMOVE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, hot); + if (eyes) drawEye(row.eye, slots.eye - EYE_SLOT / 2, mid, !item.hidden); + // Only where the pointer or the selection is: a mark on every row would crowd a long list. + const active = selected || underPointer; + row.duplicate.clear(); + if (duplicates && active) { + const hot = underPointer && hovered.part === 'duplicate'; + drawDuplicate(row.duplicate, slots.duplicate - DUPLICATE_SLOT / 2, mid, hot); } - row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1; + row.cross.clear(); + if (crosses && active) { + const hot = underPointer && hovered.part === 'cross'; + drawCross(row.cross, slots.cross - REMOVE_SLOT / 2, mid, hot); + } + const dragged = this.drag?.active && this.drag.from === index; + row.box.alpha = dragged ? 0.4 : 1; + if (row.field) row.field.alpha = dragged ? 0.4 : 1; } + this.placeRenameField(); if (this.drag?.active) { const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT; this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent); @@ -379,7 +588,21 @@ export class SortableList extends Widget { function sameHover(a: Hover, b: Hover): boolean { if (a === null || b === null || a === 'add' || b === 'add') return a === b; - return a.row === b.row && a.cross === b.cross; + return a.row === b.row && a.part === b.part; +} + +const suffixWidths = new Map(); + +/** How wide a suffix is drawn – measured once per text, the layout asks on every redraw. */ +function suffixWidth(suffix: string): number { + let width = suffixWidths.get(suffix); + if (width === undefined) { + const text = new Text({ text: suffix, style: textStyle('small') }); + width = text.width; + text.destroy(); + suffixWidths.set(suffix, width); + } + return width; } /** An open eye, or a closed one struck through – drawn rather than a glyph, so it stays crisp. */ @@ -393,6 +616,13 @@ function drawEye(g: Graphics, x: number, y: number, open: boolean): void { .stroke({ width: 1.2, color }); } +/** Two overlapping squares, the usual sign for a copy: muted, and in the text colour under the pointer. */ +function drawDuplicate(g: Graphics, x: number, y: number, hot: boolean): void { + const color = hot ? Theme.colors.text : Theme.colors.textMuted; + g.rect(x - 5, y - 5, 7, 7).stroke({ width: 1.2, color }); + g.rect(x - 2, y - 2, 7, 7).stroke({ width: 1.2, color }); +} + /** The cross that removes a row: muted, and in the danger colour under the pointer. */ function drawCross(g: Graphics, x: number, y: number, hot: boolean): void { g.moveTo(x - 4, y - 4) diff --git a/tests/core/pixel-art.test.ts b/tests/core/pixel-art.test.ts index c749daa9..39743bdc 100644 --- a/tests/core/pixel-art.test.ts +++ b/tests/core/pixel-art.test.ts @@ -49,6 +49,7 @@ import { withFrameRemoved, withLayerAdded, withLayerChanged, + withLayerDuplicated, withLayerMergedDown, withLayerMoved, withLayerRemoved, @@ -361,6 +362,25 @@ describe('edits', () => { expect(() => withLayerMergedDown(s, layer, {})).toThrow('no layer below'); }); + it('duplicates a layer right above it, naming the same cels on every frame', () => { + const { asset, layer, frame } = start(); + let s = withLayerAdded(pixelArtState(asset), 'top'); + s = withLayerChanged(s, layer, { opacity: 0.5, hidden: true }); + s = withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]); + s = withLayerDuplicated(s, 'copy', layer); + s = withLayerDuplicated(s, 'again', layer); + expect(s.layers.map((l) => [l.id, l.name])).toEqual([ + [layer, 'Layer 1'], + ['again', 'Layer 1 copy 2'], + ['copy', 'Layer 1 copy'], + ['top', 'Layer 2'], + ]); + expect(s.layers[2]).toMatchObject({ opacity: 0.5, hidden: true }); + expect(s.frames[0]!.cels).toMatchObject({ [layer]: FILE_A, copy: FILE_A, again: FILE_A }); + expect(s.frames[0]!.cels).not.toHaveProperty('top'); + expect(() => withLayerDuplicated(s, 'x', 'nobody')).toThrow('not found'); + }); + it('adds a frame empty or as a copy, moves it and keeps the last one', () => { const { asset, layer, frame } = start(); let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]); diff --git a/tests/core/tilemap.test.ts b/tests/core/tilemap.test.ts index cb001200..e83ddc39 100644 --- a/tests/core/tilemap.test.ts +++ b/tests/core/tilemap.test.ts @@ -7,11 +7,13 @@ import { import { AddTilePictureCommand } from '../../src/core/commands/tileCommands'; import { AddTilemapLayerCommand, + DuplicateTilemapLayerCommand, MoveTilemapLayerCommand, PaintTilesCommand, RemoveTilemapLayerCommand, RenameTilemapLayerCommand, SetTilemapLayerHiddenCommand, + SetTilemapLayerZCommand, } from '../../src/core/commands/tilemapCommands'; import type { TilemapAsset } from '../../src/core/model/Asset'; import { isPlaceableType } from '../../src/core/model/AssetTypes'; @@ -319,6 +321,25 @@ describe('the layers of a tilemap', () => { expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Roofs']); }); + it('duplicates a layer right above it, with its cells, its z and a name of its own', () => { + const f = makeTilemap(); + f.paint([{ x: 1, y: 1 }]); + f.stack.execute(new SetTilemapLayerZCommand(f.tilemapId, f.layerId(), 3)); + f.stack.execute(new AddTilemapLayerCommand(f.tilemapId)); + const copy = new DuplicateTilemapLayerCommand(f.tilemapId, f.layerId()); + f.stack.execute(copy); + const layers = f.assetOf().layers; + expect(layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 1 copy', 'Layer 2']); + expect(layers[1]).toMatchObject({ id: copy.layerId, z: 3, cells: layers[0]!.cells }); + // Cells of its own: painting on the copy leaves the original alone. + f.stack.execute(new PaintTilesCommand(f.tilemapId, copy.layerId, [{ x: 1, y: 1, tileId: '' }])); + expect(f.assetOf().layers[0]!.cells[cellKey(1, 1)]).toBeTruthy(); + + f.stack.undo(); + f.stack.undo(); + expect(f.assetOf().layers.map((l) => l.name)).toEqual(['Layer 1', 'Layer 2']); + }); + it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => { const f = makeTilemap(); f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground ')); diff --git a/tests/core/vectorart.test.ts b/tests/core/vectorart.test.ts index 1b1e5254..5d4c8ffa 100644 --- a/tests/core/vectorart.test.ts +++ b/tests/core/vectorart.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; import { AddVectorShapeCommand, + DuplicateVectorShapeCommand, MoveVectorShapeCommand, RemoveVectorShapeCommand, RenameVectorShapeCommand, @@ -231,6 +232,29 @@ describe('editing the shapes', () => { expect(f.art().shapes).toEqual([]); }); + it('duplicates a shape right above it, named as a copy, with outlines of its own', () => { + const f = drawing(); + const add = new AddVectorShapeCommand(f.assetId, [square()], 0x112233); + const top = new AddVectorShapeCommand(f.assetId, [square()]); + f.stack.execute(add); + f.stack.execute(top); + f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { opacity: 0.5 })); + const copy = new DuplicateVectorShapeCommand(f.assetId, add.shapeId); + f.stack.execute(copy); + f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, add.shapeId)); + const shapes = f.art().shapes; + expect(shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 1 copy 2', 'Shape 1 copy', 'Shape 2']); + expect(shapes[2]).toMatchObject({ id: copy.shapeId, fill: 0x112233, opacity: 0.5 }); + expect(shapes[2]!.rings).toEqual(shapes[0]!.rings); + // Its own outline: a point dragged on the copy leaves the original where it is. + expect(shapes[2]!.rings[0]).not.toBe(shapes[0]!.rings[0]); + + f.stack.undo(); + f.stack.undo(); + expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']); + expect(() => f.stack.execute(new DuplicateVectorShapeCommand(f.assetId, 'nobody'))).toThrow(/not found/); + }); + it('keeps the drawing order apart from the list order in no way at all', () => { const f = drawing(); const ids = ['a', 'b', 'c'].map(() => { diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index b6f4f914..61b1109e 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -463,31 +463,33 @@ export async function tilemap(t) { await undo(); await settle(); - // 40m. The painted layer is named in the field below the list, and Show singles it out in the - // scene view (#138). It sits here, not at the end, because here both layers exist: the upper - // one is painted on and holds the cell (4,0), the lower one the 3 × 3 block. - const nameField = await inPanel(t, '__gm.tilemap.layerNameAt()'); - if (nameField) { - await click(nameField.x, nameField.y); - await press('a', { ctrl: true }); - await typeText('Roofs'); - await press('Enter'); - } + // 40m. A double-click on the painted layer's name edits it in its row, and Show singles it out in + // the scene view (#138). It sits here, not at the end, because here both layers exist: the + // upper one is painted on and holds the cell (4,0), the lower one the 3 × 3 block. + const nameSpot = await inPanel(t, '__gm.tilemap.layerNameAt()'); + if (nameSpot) await t.doubleClick(nameSpot.x, nameSpot.y); + await settle(); + const nameEditing = (await json('__gm.tilemap.layerNameAt()'))?.editing; + await press('a', { ctrl: true }); + await typeText('Roofs'); + await press('Enter'); await settle(); layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); const renamedRow = await json('(__gm.tilemap.layerRowAt(0) || {}).id'); - const nameNow = await json('(__gm.tilemap.layerNameAt() || {}).value'); + const nameNow = await json('__gm.tilemap.layerNameAt()'); check( - 'the name field renames the layer painted on, and the list shows the new name', - layers[1].name === 'Roofs' && + 'a double-click on a layer name edits it in the row, and the list shows the new name', + nameEditing?.value === 'Layer 2' && + layers[1].name === 'Roofs' && layers[0].name === 'Layer 1' && renamedRow === layers[1].id && - nameNow === 'Roofs', - JSON.stringify({ layers, renamedRow, nameNow }), + nameNow?.value === 'Roofs' && + nameNow.editing === null, + JSON.stringify({ nameEditing, layers, renamedRow, nameNow }), ); - // 40p. The Z field below the name gives the painted layer a z of its own, added to the map's - // (#174): at −5 the upper layer is sorted in below the map itself and below the lower layer. + // 40p. The Z field in the row gives the painted layer a z of its own, added to the map's (#174): + // at −5 the upper layer is sorted in below the map itself and below the lower layer. const zField = await inPanel(t, '__gm.tilemap.layerZAt()'); if (zField) { await click(zField.x, zField.y); @@ -519,6 +521,25 @@ export async function tilemap(t) { JSON.stringify({ withZ, orderZ, orderBack, zBack }), ); + // 40p-ii. The duplicate mark copies the painted layer right above it, cells and all, and paints + // on the copy. Undone again: the blocks below count on two layers. + const copyMark = await inPanel(t, '__gm.tilemap.duplicateLayerAt()'); + if (copyMark) await click(copyMark.x, copyMark.y); + await settle(); + const withCopy = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`); + const paintedCopy = (await json('__gm.tilemap.paint()')).layerId; + check( + 'the duplicate mark on a layer row puts a copy with the same cells above it and paints on it', + copyMark !== null && + withCopy.length === 3 && + withCopy[2].name === 'Roofs copy' && + withCopy[2].cells === withCopy[1].cells && + paintedCopy === withCopy[2].id, + JSON.stringify({ copyMark, withCopy, paintedCopy }), + ); + await undo(); + await settle(); + // Pixels again, so the animation is cut down to one step for the reading, as in 40e. await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']"); await settle(); diff --git a/tools/e2e/47-vectorart.mjs b/tools/e2e/47-vectorart.mjs index 240d288f..dcd6f248 100644 --- a/tools/e2e/47-vectorart.mjs +++ b/tools/e2e/47-vectorart.mjs @@ -385,6 +385,51 @@ export async function vectorArt(t) { JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }), ); + // 47e-ii. The duplicate mark puts a copy right above its shape and selects it; a double-click on + // a name edits it in the row. Both are undone again, the blocks after count on the shapes. + const fieldName = (await shapeOf('va-field')).name; + const duplicateAt = await json("__gm.vectorArt.shapeDuplicateAt('va-field')"); + if (duplicateAt) await click(duplicateAt.x, duplicateAt.y); + await settle(); + const withCopy = await json("__gm.vectorArt.shapes('va-art')"); + const copyIndex = withCopy.findIndex((shape) => shape.id === 'va-field') + 1; + check( + 'the duplicate mark on a shape row puts a copy right above it and selects the copy', + duplicateAt !== null && + withCopy.length === before47e + 1 && + withCopy[copyIndex]?.name === `${fieldName} copy` && + withCopy[copyIndex]?.fill === 0x20c0e8 && + (await json('__gm.vectorArt.selected()')) === withCopy[copyIndex]?.id, + JSON.stringify({ duplicateAt, withCopy: withCopy.map((s) => s.name), copyIndex }), + ); + await undo(); + await settle(); + + // Picked first: with only the asset selected the list stands lower, under the asset's rows, and + // the first click of a double-click would move it up from under the pointer. + const pickAt = await json("__gm.vectorArt.shapeNameAt('va-field')"); + if (pickAt) await click(pickAt.x, pickAt.y); + await settle(); + const nameAt = await json("__gm.vectorArt.shapeNameAt('va-field')"); + if (nameAt) await t.doubleClick(nameAt.x, nameAt.y); + await settle(); + const renaming = await json('__gm.vectorArt.shapeRenaming()'); + await press('a', { ctrl: true }); + await typeText('Meadow'); + await press('Enter'); + await settle(); + const renamed = await shapeOf('va-field'); + check( + 'a double-click on a shape name edits it in the row, and Enter keeps the new name', + renaming?.value === fieldName && + renamed?.name === 'Meadow' && + (await json('__gm.vectorArt.shapeRenaming()')) === null && + (await shapeIds()).length === before47e, + JSON.stringify({ nameAt, renaming, renamed: renamed?.name }), + ); + await undo(); + await settle(); + // 47f (stage 5). The way a user takes, which the blocks before took by hand: the asset is made // from the + menu, its row opens the tab, and a sprite wears the drawing through its // Texture row – the picture picker of #185, which until here only unit tests had seen. diff --git a/tools/e2e/48-pixelart.mjs b/tools/e2e/48-pixelart.mjs index 2c2a40a3..8afec830 100644 --- a/tools/e2e/48-pixelart.mjs +++ b/tools/e2e/48-pixelart.mjs @@ -233,10 +233,11 @@ export async function pixelArt(t) { const hiddenFlag = (await asset(id)).layers.at(-1).hidden; await click(eyeAt.x, eyeAt.y); await settle(); - const opacity = await control('layerOpacity'); + // The opacity stands in the layer's row, in percent. + const opacity = (await listRow('layer', top)).opacity; await click(opacity.x, opacity.y); await press('a', { ctrl: true }); - await typeText('0.5'); + await typeText('50'); await press('Enter'); await settle(); const half = await screen(10, 10); @@ -256,7 +257,7 @@ export async function pixelArt(t) { ) .join(''); check( - 'the eye hides a layer, which is then not drawn, and a layer at half opacity is mixed with the one below', + 'the eye hides a layer, which is then not drawn, and a layer at 50 % in its row is mixed with the one below', colourDistance(blueShown, HEX[BLUE]) < 10 && hiddenFlag === true && colourDistance(hidden, HEX[GREEN]) < 10 && @@ -266,6 +267,67 @@ export async function pixelArt(t) { JSON.stringify({ blueShown, hiddenFlag, hidden, half, mixed, opacity: art.layers.at(-1).opacity }), ); + // 48f-i. Scrubbing the opacity drags the number, not the row: the order stays, the canvas follows + // while the pointer moves, and the whole scrub is one undo step back to 50 %. + const orderBeforeScrub = await json('__gm.pixelArt.layerRows()'); + const scrubFrom = (await listRow('layer', top)).opacity; + await drag(scrubFrom.x, scrubFrom.y, scrubFrom.x + 30, scrubFrom.y + 12, { steps: 10 }); + await settle(); + const scrubbed = (await asset(id)).layers.at(-1).opacity; + const orderAfterScrub = await json('__gm.pixelArt.layerRows()'); + const scrubText = (await listRow('layer', top)).opacity?.value; + await undo(); + await settle(); + const scrubUndone = (await asset(id)).layers.at(-1).opacity; + check( + 'scrubbing the opacity in a row changes it without moving the row, and one undo takes it back', + scrubbed === 0.8 && + scrubText === '80' && + orderAfterScrub.join() === orderBeforeScrub.join() && + scrubUndone === 0.5, + JSON.stringify({ scrubbed, scrubText, orderBeforeScrub, orderAfterScrub, scrubUndone }), + ); + + // 48f-ii-a. The duplicate mark copies the layer right above it and paints on the copy; a + // double-click on its name edits it in the row. Both are undone again. + const layersBeforeCopy = (await asset(id)).layers; + const copyMark = (await listRow('layer', top)).duplicate; + if (copyMark) await click(copyMark.x, copyMark.y); + await settle(); + const withLayerCopy = await asset(id); + const layerCopy = withLayerCopy.layers.at(-1); + check( + 'the duplicate mark on a layer row puts a copy above it, with the same pixels, and paints on it', + copyMark != null && + withLayerCopy.layers.length === layersBeforeCopy.length + 1 && + layerCopy.name === `${layersBeforeCopy.at(-1).name} copy` && + layerCopy.opacity === 0.5 && + withLayerCopy.frames[0].cels[layerCopy.id] === withLayerCopy.frames[0].cels[top] && + (await json('__gm.pixelArt.layerId()')) === layerCopy.id, + JSON.stringify({ copyMark, layers: withLayerCopy.layers, cels: withLayerCopy.frames[0].cels }), + ); + await undo(); + await settle(); + + const nameSpot = (await listRow('layer', top)).name; + await t.doubleClick(nameSpot.x, nameSpot.y); + await settle(); + const editing = (await listRow('layer', top)).renaming; + await press('a', { ctrl: true }); + await typeText('Ink'); + await press('Enter'); + await settle(); + const renamedLayer = (await asset(id)).layers.find((l) => l.id === top); + check( + 'a double-click on a layer name edits it in the row, and Enter keeps the new name', + editing?.value === layersBeforeCopy.at(-1).name && + renamedLayer?.name === 'Ink' && + (await listRow('layer', top)).renaming === null, + JSON.stringify({ editing, renamed: renamedLayer?.name }), + ); + await undo(); + await settle(); + // 48f-ii. Each layer row shows that layer alone, on the frame painted, and the list is dragged into // another order. The picture's centre is canvas pixel (16, 16): green below, empty above. const bottom = art.layers[0].id; @@ -311,11 +373,13 @@ export async function pixelArt(t) { ); // 48h. Frames: a copy, a painted difference, onion skin and the playing preview. - await press_('frame:duplicate'); + const frameMark = (await listRow('frame', await json('__gm.pixelArt.frameId()'))).duplicate; + await click(frameMark.x, frameMark.y); + await settle(); art = await asset(id); const second = await json('__gm.pixelArt.frameId()'); check( - 'Duplicate adds a copy of the frame after it and paints on the copy', + 'the duplicate mark on a frame row adds a copy of the frame after it and paints on the copy', art.frames.length === 2 && art.frames[1].id === second && JSON.stringify(art.frames[1].cels) === JSON.stringify(art.frames[0].cels) &&