Lists duplicate, rename and edit values right in their rows
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:
1 parent
ae0247d86f
commit
bbbf34109b
19 files changed
+739
-145
No files matched your search
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { Project } from '../model/Project';
|
|||||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||||
import { localMatrixOf, transformOfObject } from '../properties/transform';
|
import { localMatrixOf, transformOfObject } from '../properties/transform';
|
||||||
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
import { moveItem, type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||||
|
import { copyName } from '../util/names';
|
||||||
import { nextNumberedName } from './naming';
|
import { nextNumberedName } from './naming';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
import { requireTilemap, requireTilemapLayer } from './require';
|
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. */
|
/** 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 {
|
export class RemoveTilemapLayerCommand implements Command {
|
||||||
readonly label = 'Remove layer';
|
readonly label = 'Remove layer';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { cloneVectorRing, SHAPE_FILL_DEFAULT } from '../model/vectorart';
|
|||||||
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
|
import { combineBlocker, combinedRings, type ShapeBoolean } from '../model/vectorBoolean';
|
||||||
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
|
import { cloneGradient, clonePaint, MIN_STOPS } from '../model/vectorPaint';
|
||||||
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
import { moveItem, type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||||
|
import { copyName } from '../util/names';
|
||||||
import { type Command, CommandError } from './Command';
|
import { type Command, CommandError } from './Command';
|
||||||
import { nextNumberedName } from './naming';
|
import { nextNumberedName } from './naming';
|
||||||
import { requireVectorArt, requireVectorShape } from './require';
|
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. */
|
/** Takes a shape out; unlike a tilemap layer the last one may go – an empty drawing is a drawing. */
|
||||||
export class RemoveVectorShapeCommand implements Command {
|
export class RemoveVectorShapeCommand implements Command {
|
||||||
readonly label = 'Remove shape';
|
readonly label = 'Remove shape';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* one that cannot do what is asked throws a `PixelEditError` with the reason in words.
|
* one that cannot do what is asked throws a `PixelEditError` with the reason in words.
|
||||||
*/
|
*/
|
||||||
import type { PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
|
import type { PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
|
||||||
|
import { copyName } from '../util/names';
|
||||||
import { PIXEL_CANVAS_MAX } from './pixelCel';
|
import { PIXEL_CANVAS_MAX } from './pixelCel';
|
||||||
|
|
||||||
export interface PixelArtState {
|
export interface PixelArtState {
|
||||||
@@ -83,6 +84,26 @@ export function withLayerAdded(state: PixelArtState, id: string, aboveId = ''):
|
|||||||
return next;
|
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. */
|
/** 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 {
|
export function withLayerRemoved(state: PixelArtState, layerId: string): PixelArtState {
|
||||||
const next = cloneState(state);
|
const next = cloneState(state);
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
+54
-16
@@ -502,8 +502,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
addColor: side.addColorButton,
|
addColor: side.addColorButton,
|
||||||
removeColor: side.removeColorButton,
|
removeColor: side.removeColorButton,
|
||||||
createPalette: side.createPaletteButton,
|
createPalette: side.createPaletteButton,
|
||||||
layerName: side.layerName,
|
|
||||||
layerOpacity: side.layerOpacity,
|
|
||||||
onion: side.onionBox,
|
onion: side.onionBox,
|
||||||
tiles: side.tilesBox,
|
tiles: side.tilesBox,
|
||||||
fps: side.fpsField,
|
fps: side.fpsField,
|
||||||
@@ -512,7 +510,6 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
resize: editor.pixelArtEditor.resizeButton,
|
resize: editor.pixelArtEditor.resizeButton,
|
||||||
...Object.fromEntries([...side.toolButtons].map(([tool, button]) => [tool, button])),
|
...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.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.
|
// 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;
|
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;
|
* 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) => {
|
layerRow: (layerId: string) => {
|
||||||
const side = editor.pixelArtEditor.side;
|
const side = editor.pixelArtEditor.side;
|
||||||
side.reveal(side.layers);
|
side.reveal(side.layers);
|
||||||
const row = side.layers.rowGlobal(layerId);
|
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,
|
x: name.x,
|
||||||
y: row.y + row.h / 2,
|
y: name.y,
|
||||||
thumb: side.layers.thumbGlobal(layerId),
|
thumb: side.layers.thumbGlobal(layerId),
|
||||||
|
name,
|
||||||
eye: side.layers.eyeGlobal(layerId),
|
eye: side.layers.eyeGlobal(layerId),
|
||||||
|
duplicate: side.layers.duplicateGlobal(layerId),
|
||||||
remove: side.layers.removeGlobal(layerId),
|
remove: side.layers.removeGlobal(layerId),
|
||||||
|
opacity: opacity ? { ...centreOf(opacity), value: opacity.value } : null,
|
||||||
|
renaming: renaming ? { ...centreOf(renaming), value: renaming.value } : null,
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
},
|
},
|
||||||
@@ -562,6 +569,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
x: row.x + row.w / 2,
|
x: row.x + row.w / 2,
|
||||||
y: row.y + row.h / 2,
|
y: row.y + row.h / 2,
|
||||||
thumb: side.frames.thumbGlobal(frameId),
|
thumb: side.frames.thumbGlobal(frameId),
|
||||||
|
duplicate: side.frames.duplicateGlobal(frameId),
|
||||||
remove: side.frames.removeGlobal(frameId),
|
remove: side.frames.removeGlobal(frameId),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
@@ -662,6 +670,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
|
shapeRowAt: (shapeId: string) => currentShapeList()?.rowGlobal(shapeId) ?? null,
|
||||||
/** The cross on a shape's row. */
|
/** The cross on a shape's row. */
|
||||||
shapeRemoveAt: (shapeId: string) => currentShapeList()?.removeGlobal(shapeId) ?? null,
|
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. */
|
/** The Paint tool (#186): its button, the "Handles" menu, and which part it shows. */
|
||||||
paintButton: () => centreOf(vectorArtEditor.paintButton),
|
paintButton: () => centreOf(vectorArtEditor.paintButton),
|
||||||
paintTarget: () => ({
|
paintTarget: () => ({
|
||||||
@@ -784,15 +800,37 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
},
|
},
|
||||||
/** The plus in the layer list's header. */
|
/** The plus in the layer list's header. */
|
||||||
addLayerAt: () => currentTilemapWidgets()?.layers.addGlobal() ?? null,
|
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: () => {
|
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;
|
return field ? { ...centreOf(field), value: field.value } : null;
|
||||||
},
|
},
|
||||||
/** The Z field of the painted layer, with what it holds (#174). */
|
/** The duplicate mark on the painted layer's row. */
|
||||||
layerZAt: () => {
|
duplicateLayerAt: () => {
|
||||||
const field = currentTilemapWidgets()?.layerZ;
|
const widgets = currentTilemapWidgets();
|
||||||
return field ? { ...centreOf(field), value: field.value } : null;
|
const target = paintedTilemap(state);
|
||||||
|
return widgets && target ? widgets.layers.duplicateGlobal(paintedLayerId(state, target.asset)) : null;
|
||||||
},
|
},
|
||||||
focusAt: () => {
|
focusAt: () => {
|
||||||
const button = currentTilemapWidgets()?.focus;
|
const button = currentTilemapWidgets()?.focus;
|
||||||
@@ -802,9 +840,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
|||||||
layerRowAt: (index: number) => {
|
layerRowAt: (index: number) => {
|
||||||
const list = currentTilemapWidgets()?.layers;
|
const list = currentTilemapWidgets()?.layers;
|
||||||
const id = list?.shownIds[index];
|
const id = list?.shownIds[index];
|
||||||
const row = id ? list.rowGlobal(id) : null;
|
// On the name: the middle of the row may be the Z field, which a press would scrub.
|
||||||
if (!list || !id || !row) return null;
|
const name = id ? list.nameGlobal(id) : null;
|
||||||
const name = { x: row.x + row.w / 2, y: row.y + row.h / 2 };
|
if (!list || !id || !name) return null;
|
||||||
// `y` is the row's own line, so a check can tell whether it is inside the panel.
|
// `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) };
|
return { id, y: name.y, name, visible: list.eyeGlobal(id) };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
import {
|
import {
|
||||||
AddVectorShapeCommand,
|
AddVectorShapeCommand,
|
||||||
CombineVectorShapesCommand,
|
CombineVectorShapesCommand,
|
||||||
|
DuplicateVectorShapeCommand,
|
||||||
MoveVectorShapeCommand,
|
MoveVectorShapeCommand,
|
||||||
RemoveVectorShapeCommand,
|
RemoveVectorShapeCommand,
|
||||||
RemoveVectorShapesCommand,
|
RemoveVectorShapesCommand,
|
||||||
@@ -387,12 +388,20 @@ function addShapeList(host: InspectorHost, asset: VectorArtAsset, selectedIds: r
|
|||||||
asset.id,
|
asset.id,
|
||||||
asset.shapes.map((shape) => shape.id),
|
asset.shapes.map((shape) => shape.id),
|
||||||
selectedIds,
|
selectedIds,
|
||||||
(shapeId, toggle) =>
|
{
|
||||||
|
onPick: (shapeId, toggle) =>
|
||||||
toggle
|
toggle
|
||||||
? host.state.toggleShape(asset.id, shapeId)
|
? host.state.toggleShape(asset.id, shapeId)
|
||||||
: host.state.select(shapeSelection(asset.id, [shapeId])),
|
: host.state.select(shapeSelection(asset.id, [shapeId])),
|
||||||
(from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
|
onReorder: (from, to) => void host.state.execute(new MoveVectorShapeCommand(asset.id, from, to)),
|
||||||
(shapeId) => removeShape(host, asset.id, shapeId),
|
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;
|
built = list;
|
||||||
host.body.add(list);
|
host.body.add(list);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
AddTilemapLayerCommand,
|
AddTilemapLayerCommand,
|
||||||
|
DuplicateTilemapLayerCommand,
|
||||||
MoveTilemapLayerCommand,
|
MoveTilemapLayerCommand,
|
||||||
RemoveTilemapLayerCommand,
|
RemoveTilemapLayerCommand,
|
||||||
RenameTilemapLayerCommand,
|
RenameTilemapLayerCommand,
|
||||||
@@ -25,11 +26,9 @@ import {
|
|||||||
Dropdown,
|
Dropdown,
|
||||||
HStack,
|
HStack,
|
||||||
Menu,
|
Menu,
|
||||||
NumberField,
|
|
||||||
Panel,
|
Panel,
|
||||||
ScrollContainer,
|
ScrollContainer,
|
||||||
SortableList,
|
SortableList,
|
||||||
TextField,
|
|
||||||
Theme,
|
Theme,
|
||||||
ui,
|
ui,
|
||||||
VStack,
|
VStack,
|
||||||
@@ -133,12 +132,11 @@ export interface TilemapPanelWidgets {
|
|||||||
tools: Map<TilemapTool, Button>;
|
tools: Map<TilemapTool, Button>;
|
||||||
/** Null while the project has no tile. */
|
/** Null while the project has no tile. */
|
||||||
palette: TilePalette | null;
|
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;
|
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). */
|
/** How the other layers are shown in the scene view (#138). */
|
||||||
focus: Dropdown;
|
focus: Dropdown;
|
||||||
}
|
}
|
||||||
@@ -258,22 +256,6 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
|||||||
const { state } = host;
|
const { state } = host;
|
||||||
const paint = state.tilemapPaint;
|
const paint = state.tilemapPaint;
|
||||||
const painted = paintedLayerId(state, asset);
|
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 ?? '', {
|
const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', {
|
||||||
onClick: () =>
|
onClick: () =>
|
||||||
new Menu(
|
new Menu(
|
||||||
@@ -297,10 +279,23 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
|||||||
new SetTilemapLayerHiddenCommand(asset.id, layerId, isLayerVisible(findLayer(asset, layerId)!)),
|
new SetTilemapLayerHiddenCommand(asset.id, layerId, isLayerVisible(findLayer(asset, layerId)!)),
|
||||||
),
|
),
|
||||||
onAdd: () => state.execute(new AddTilemapLayerCommand(asset.id)),
|
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)),
|
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,
|
focus,
|
||||||
};
|
};
|
||||||
built = widgets;
|
built = widgets;
|
||||||
@@ -342,16 +337,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
|||||||
[painted],
|
[painted],
|
||||||
);
|
);
|
||||||
host.body.add(widgets.layers);
|
host.body.add(widgets.layers);
|
||||||
addNote(host, 'Drag a row to draw it earlier or later.', 'small');
|
addNote(host, 'Drag a row to reorder, double-click a name to rename.', 'small');
|
||||||
// Below the list rather than in it: a press on a row picks the layer, and a text field in its
|
addNote(host, 'The number is the layer’s Z, added to the map’s.', 'small');
|
||||||
// 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');
|
|
||||||
const showRow = labelledRow('Show').row;
|
const showRow = labelledRow('Show').row;
|
||||||
showRow.add(focus, 1);
|
showRow.add(focus, 1);
|
||||||
host.body.add(showRow);
|
host.body.add(showRow);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
addFrame,
|
addFrame,
|
||||||
addLayer,
|
addLayer,
|
||||||
addPaletteColor,
|
addPaletteColor,
|
||||||
|
duplicateLayer,
|
||||||
mergeLayerDown,
|
mergeLayerDown,
|
||||||
moveFrame,
|
moveFrame,
|
||||||
moveLayer,
|
moveLayer,
|
||||||
@@ -69,12 +70,11 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
readonly removeColorButton = new Button('Remove last');
|
readonly removeColorButton = new Button('Remove last');
|
||||||
readonly createPaletteButton = new Button('Create palette');
|
readonly createPaletteButton = new Button('Create palette');
|
||||||
private readonly paletteNote = new Label('', { style: 'muted' });
|
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 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>;
|
readonly layerButtons: Record<'merge', Button>;
|
||||||
|
/** Each row duplicates its frame right after it. */
|
||||||
readonly frames: SortableList;
|
readonly frames: SortableList;
|
||||||
readonly frameButtons: Record<'duplicate', Button>;
|
|
||||||
private readonly thumbs = new PixelThumbs();
|
private readonly thumbs = new PixelThumbs();
|
||||||
private readonly stopCels: () => void;
|
private readonly stopCels: () => void;
|
||||||
readonly onionBox = new Checkbox(false);
|
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)),
|
this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)),
|
||||||
onToggleHidden: (id) => this.toggleHidden(id),
|
onToggleHidden: (id) => this.toggleHidden(id),
|
||||||
onAdd: () => this.addLayer(),
|
onAdd: () => this.addLayer(),
|
||||||
|
onDuplicate: (id) => this.duplicateLayer(id),
|
||||||
onRemove: (id) => this.withAsset((a) => removeLayer(state, a, 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)),
|
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, id)),
|
||||||
});
|
});
|
||||||
c.add(this.layers);
|
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 = {
|
this.layerButtons = {
|
||||||
merge: new Button('Merge down', {
|
merge: new Button('Merge down', {
|
||||||
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
|
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),
|
onPick: (id) => this.canvas.selectFrame(id),
|
||||||
onMove: (from, to) =>
|
onMove: (from, to) =>
|
||||||
this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, 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)),
|
onRemove: (id) => this.withAsset((a) => removeFrame(state, a, id)),
|
||||||
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)),
|
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)),
|
||||||
});
|
});
|
||||||
c.add(this.frames);
|
c.add(this.frames);
|
||||||
this.frameButtons = {
|
|
||||||
duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }),
|
|
||||||
};
|
|
||||||
c.add(row(this.frameButtons.duplicate));
|
|
||||||
|
|
||||||
c.add(heading('View'));
|
c.add(heading('View'));
|
||||||
this.onionBox.onChange = (on) => {
|
this.onionBox.onChange = (on) => {
|
||||||
@@ -296,6 +302,36 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
if (id) this.canvas.selectLayer(id);
|
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<string, number>();
|
||||||
|
|
||||||
|
/** 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 {
|
private toggleHidden(layerId: string): void {
|
||||||
const asset = this.canvas.asset();
|
const asset = this.canvas.asset();
|
||||||
const layer = asset?.layers.find((l) => l.id === layerId);
|
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);
|
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();
|
const asset = this.canvas.asset();
|
||||||
if (!asset) return;
|
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);
|
if (id) this.canvas.selectFrame(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,18 +378,10 @@ export class PixelArtSide extends ScrollContainer {
|
|||||||
|
|
||||||
const layerId = this.canvas.layerId;
|
const layerId = this.canvas.layerId;
|
||||||
this.layers.setItems(
|
this.layers.setItems(
|
||||||
(asset?.layers ?? []).map((layer) => ({
|
(asset?.layers ?? []).map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden })),
|
||||||
id: layer.id,
|
|
||||||
label: layer.name,
|
|
||||||
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
|
|
||||||
hidden: layer.hidden,
|
|
||||||
})),
|
|
||||||
layerId ? [layerId] : [],
|
layerId ? [layerId] : [],
|
||||||
);
|
);
|
||||||
const layer = asset?.layers.find((l) => l.id === layerId);
|
const index = asset ? asset.layers.findIndex((l) => l.id === layerId) : -1;
|
||||||
this.layerName.setValue(layer?.name ?? '');
|
|
||||||
this.layerOpacity.setNumber(layer?.opacity ?? 1);
|
|
||||||
const index = asset && layer ? asset.layers.indexOf(layer) : -1;
|
|
||||||
this.layerButtons.merge.setEnabled(index > 0);
|
this.layerButtons.merge.setEnabled(index > 0);
|
||||||
|
|
||||||
const frameId = this.canvas.frameId;
|
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) })),
|
(asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })),
|
||||||
frameId ? [frameId] : [],
|
frameId ? [frameId] : [],
|
||||||
);
|
);
|
||||||
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
|
|
||||||
this.frameButtons.duplicate.setEnabled(frameIndex >= 0);
|
|
||||||
this.relayoutContent();
|
this.relayoutContent();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
withFrameRemoved,
|
withFrameRemoved,
|
||||||
withLayerAdded,
|
withLayerAdded,
|
||||||
withLayerChanged,
|
withLayerChanged,
|
||||||
|
withLayerDuplicated,
|
||||||
withLayerMergedDown,
|
withLayerMergedDown,
|
||||||
withLayerMoved,
|
withLayerMoved,
|
||||||
withLayerRemoved,
|
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 : '';
|
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 {
|
export function removeLayer(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
|
||||||
return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId));
|
return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* `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
|
* Ctrl or Shift, which adds the shape to the selection or takes it out (#187). `onDuplicate` and
|
||||||
* cross on a row. There is no plus: a shape is drawn with the Pen, and "Add shape" stays below.
|
* `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(
|
constructor(
|
||||||
private readonly state: EditorState,
|
private readonly state: EditorState,
|
||||||
private readonly assetId: string,
|
private readonly assetId: string,
|
||||||
shapeIds: readonly string[],
|
shapeIds: readonly string[],
|
||||||
selectedIds: readonly string[],
|
selectedIds: readonly string[],
|
||||||
onPick: (shapeId: string, toggle: boolean) => void,
|
rows: {
|
||||||
onReorder: (from: number, to: number) => void,
|
onPick: (shapeId: string, toggle: boolean) => void;
|
||||||
onRemove: (shapeId: string) => void,
|
onReorder: (from: number, to: number) => void;
|
||||||
|
onDuplicate: (shapeId: string) => void;
|
||||||
|
onRemove: (shapeId: string) => void;
|
||||||
|
onRename: (shapeId: string, name: string) => void;
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
title: 'Shapes',
|
title: 'Shapes',
|
||||||
reversed: true,
|
reversed: true,
|
||||||
multiSelect: true,
|
multiSelect: true,
|
||||||
onPick,
|
onPick: rows.onPick,
|
||||||
onMove: onReorder,
|
onMove: rows.onReorder,
|
||||||
onRemove,
|
onDuplicate: rows.onDuplicate,
|
||||||
|
onRemove: rows.onRemove,
|
||||||
|
onRename: rows.onRename,
|
||||||
thumb: (id, art, size) => this.paintSwatch(id, art, size),
|
thumb: (id, art, size) => this.paintSwatch(id, art, size),
|
||||||
});
|
});
|
||||||
const asset = this.asset();
|
const asset = this.asset();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export {
|
|||||||
SortableList,
|
SortableList,
|
||||||
type SortableItem,
|
type SortableItem,
|
||||||
type SortableListOptions,
|
type SortableListOptions,
|
||||||
|
type SortableValue,
|
||||||
type ThumbClip,
|
type ThumbClip,
|
||||||
} from './widgets/SortableList';
|
} from './widgets/SortableList';
|
||||||
export { dropGap, dropMove, rowOrder } from './sortable';
|
export { dropGap, dropMove, rowOrder } from './sortable';
|
||||||
|
|||||||
+266
-36
@@ -1,26 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* A list whose rows are dragged into another order – the layer list of every editor that has one:
|
* 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
|
* 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
|
* carry a small picture the owner paints, a number typed or scrubbed in place, an eye that hides the
|
||||||
* that removes it. Over the rows a header names the list and holds the plus that adds an item.
|
* 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
|
* The duplicate mark and the cross only show on the row under the pointer and on the marked ones, so
|
||||||
* owner runs its command, and the next `setItems` shows the result.
|
* 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 { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||||
|
import { pressCount } from '../clicks';
|
||||||
import { dropGap, dropMove, rowOrder } from '../sortable';
|
import { dropGap, dropMove, rowOrder } from '../sortable';
|
||||||
import { Theme, textStyle } from '../Theme';
|
import { Theme, textStyle } from '../Theme';
|
||||||
|
import { ui } from '../UIContext';
|
||||||
import { type Size, Widget } from '../Widget';
|
import { type Size, Widget } from '../Widget';
|
||||||
import { truncate } from './Label';
|
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. */
|
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||||
export const SORTABLE_ROW_HEIGHT = 30;
|
export const SORTABLE_ROW_HEIGHT = 30;
|
||||||
const THUMB = 22;
|
const THUMB = 22;
|
||||||
const PAD = 4;
|
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. */
|
/** Width at the right end of a row that belongs to the eye. */
|
||||||
const EYE_SLOT = 26;
|
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. */
|
/** 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;
|
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. */
|
/** Height of the header, where there is one. */
|
||||||
const HEADER_HEIGHT = 24;
|
const HEADER_HEIGHT = 24;
|
||||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
/** 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;
|
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 {
|
export interface SortableListOptions {
|
||||||
/** The model's last item is the topmost row, as in every layer list: it is drawn last. */
|
/** The model's last item is the topmost row, as in every layer list: it is drawn last. */
|
||||||
reversed?: boolean;
|
reversed?: boolean;
|
||||||
@@ -57,10 +88,15 @@ export interface SortableListOptions {
|
|||||||
onMove: (from: number, to: number) => void;
|
onMove: (from: number, to: number) => void;
|
||||||
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */
|
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */
|
||||||
onToggleHidden?: (id: string) => void;
|
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. */
|
/** Setting this gives every row a cross at its right end; a press on it reports the item. */
|
||||||
onRemove?: (id: string) => void;
|
onRemove?: (id: string) => void;
|
||||||
/** No cross while a single item is left – a map or a picture needs one layer to paint on. */
|
/** No cross while a single item is left – a map or a picture needs one layer to paint on. */
|
||||||
keepOne?: boolean;
|
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. */
|
/** Stands in the header, left. */
|
||||||
title?: string;
|
title?: string;
|
||||||
/** Setting this puts a plus at the right end of the header. */
|
/** Setting this puts a plus at the right end of the header. */
|
||||||
@@ -75,7 +111,10 @@ interface Row {
|
|||||||
label: Text;
|
label: Text;
|
||||||
hint: Text;
|
hint: Text;
|
||||||
eye: Graphics;
|
eye: Graphics;
|
||||||
|
duplicate: Graphics;
|
||||||
cross: Graphics;
|
cross: Graphics;
|
||||||
|
/** Only with `value`; a child of the list, not of `box`, so it takes the pointer. */
|
||||||
|
field: NumberField | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RowDrag {
|
interface RowDrag {
|
||||||
@@ -89,8 +128,21 @@ interface RowDrag {
|
|||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the pointer is over: a row, a row's cross, or the plus. */
|
/** Which part of a row a point is on. */
|
||||||
type Hover = { row: number; cross: boolean } | 'add' | null;
|
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 {
|
export class SortableList extends Widget {
|
||||||
private readonly background = new Graphics();
|
private readonly background = new Graphics();
|
||||||
@@ -103,6 +155,8 @@ export class SortableList extends Widget {
|
|||||||
private selected: readonly string[] = [];
|
private selected: readonly string[] = [];
|
||||||
private over: Hover = null;
|
private over: Hover = null;
|
||||||
private drag: RowDrag | null = 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) {
|
constructor(private readonly options: SortableListOptions) {
|
||||||
super();
|
super();
|
||||||
@@ -138,8 +192,13 @@ export class SortableList extends Widget {
|
|||||||
this.shown = rowOrder(items, this.options.reversed ?? false);
|
this.shown = rowOrder(items, this.options.reversed ?? false);
|
||||||
this.selected = selectedIds;
|
this.selected = selectedIds;
|
||||||
while (this.rows.length < this.shown.length) this.rows.push(this.buildRow());
|
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 (countChanged) this.drag = null;
|
||||||
|
if (this.renaming && !this.shown.some((item) => item.id === this.renaming?.id)) ui().focus.blur();
|
||||||
this.invalidate();
|
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) };
|
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. */
|
/** The centre of an item's eye (test hook); null without eyes or without that row. */
|
||||||
eyeGlobal(id: string): { x: number; y: number } | null {
|
eyeGlobal(id: string): { x: number; y: number } | null {
|
||||||
const row = this.rowGlobal(id);
|
const row = this.rowGlobal(id);
|
||||||
if (!row || !this.options.onToggleHidden) return null;
|
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. */
|
/** 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 };
|
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. */
|
/** The centre of the plus (test hook); null without one. */
|
||||||
addGlobal(): { x: number; y: number } | null {
|
addGlobal(): { x: number; y: number } | null {
|
||||||
if (!this.options.onAdd) return 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);
|
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. */
|
/** Where the name starts. */
|
||||||
private get crossWidth(): number {
|
private get textX(): number {
|
||||||
return this.options.onRemove ? REMOVE_SLOT : 0;
|
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 {
|
private buildRow(): Row {
|
||||||
@@ -215,12 +321,35 @@ export class SortableList extends Widget {
|
|||||||
const label = new Text({ text: '', style: textStyle('body') });
|
const label = new Text({ text: '', style: textStyle('body') });
|
||||||
const hint = new Text({ text: '', style: textStyle('small') });
|
const hint = new Text({ text: '', style: textStyle('small') });
|
||||||
const eye = new Graphics();
|
const eye = new Graphics();
|
||||||
|
const duplicate = new Graphics();
|
||||||
const cross = new Graphics();
|
const cross = new Graphics();
|
||||||
box.eventMode = 'none';
|
box.eventMode = 'none';
|
||||||
box.addChild(art, clip, label, hint, eye, cross);
|
box.addChild(art, clip, label, hint, eye, duplicate, cross);
|
||||||
art.mask = clip;
|
art.mask = clip;
|
||||||
this.addChildAt(box, this.getChildIndex(this.marker));
|
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 {
|
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;
|
return this.options.onAdd && local.x >= this.w - REMOVE_SLOT ? 'add' : null;
|
||||||
const row = this.rowAt(local.y);
|
const row = this.rowAt(local.y);
|
||||||
if (row < 0) return null;
|
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 {
|
private onPress(e: FederatedPointerEvent): void {
|
||||||
@@ -247,13 +385,22 @@ export class SortableList extends Widget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = this.shown[hit.row]!.id;
|
const id = this.shown[hit.row]!.id;
|
||||||
if (hit.cross) {
|
if (hit.part === 'cross') {
|
||||||
this.options.onRemove?.(id);
|
this.options.onRemove?.(id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const eyeRight = this.w - this.crossWidth;
|
if (hit.part === 'duplicate') {
|
||||||
if (this.options.onToggleHidden && local.x >= eyeRight - EYE_SLOT && local.x < eyeRight) {
|
this.options.onDuplicate?.(id);
|
||||||
this.options.onToggleHidden(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;
|
return;
|
||||||
}
|
}
|
||||||
if (this.options.multiSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) {
|
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 };
|
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 {
|
private onMove(e: FederatedPointerEvent): void {
|
||||||
const drag = this.drag;
|
const drag = this.drag;
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
@@ -297,18 +481,22 @@ export class SortableList extends Widget {
|
|||||||
.fill(Theme.colors.control);
|
.fill(Theme.colors.control);
|
||||||
this.marker.clear();
|
this.marker.clear();
|
||||||
this.redrawHeader();
|
this.redrawHeader();
|
||||||
|
const slots = this.slots;
|
||||||
|
const nameRight = this.nameRight;
|
||||||
const eyes = Boolean(this.options.onToggleHidden);
|
const eyes = Boolean(this.options.onToggleHidden);
|
||||||
const crosses = this.canRemove;
|
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 thumbs = this.options.thumb;
|
||||||
const textX = thumbs ? PAD * 2 + THUMB : PAD * 2;
|
const textX = this.textX;
|
||||||
const hovered = this.over !== 'add' ? this.over : null;
|
const hovered = this.over !== 'add' ? this.over : null;
|
||||||
for (const [index, item] of this.shown.entries()) {
|
for (const [index, item] of this.shown.entries()) {
|
||||||
const row = this.rows[index]!;
|
const row = this.rows[index]!;
|
||||||
const y = top + index * SORTABLE_ROW_HEIGHT;
|
const y = top + index * SORTABLE_ROW_HEIGHT;
|
||||||
row.box.position.set(0, y);
|
row.box.position.set(0, y);
|
||||||
const selected = this.selected.includes(item.id);
|
const selected = this.selected.includes(item.id);
|
||||||
if (selected || hovered?.row === index)
|
const underPointer = hovered?.row === index;
|
||||||
|
if (selected || underPointer)
|
||||||
this.background
|
this.background
|
||||||
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
|
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
|
||||||
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
|
.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;
|
row.art.alpha = item.hidden ? 0.35 : 1;
|
||||||
|
|
||||||
const eyeW = eyes ? EYE_SLOT : 0;
|
// The number, with its suffix right of it; without one, the owner's hint stands there.
|
||||||
row.hint.text = item.hint ?? '';
|
const number = value?.get(item.id) ?? null;
|
||||||
const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0;
|
row.hint.text = value ? (number !== null ? (value.suffix ?? '') : '') : (item.hint ?? '');
|
||||||
row.hint.position.set(
|
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,
|
(SORTABLE_ROW_HEIGHT - row.hint.height) / 2,
|
||||||
);
|
);
|
||||||
row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted');
|
const hintW = !value && row.hint.text ? row.hint.width + PAD * 2 : 0;
|
||||||
row.label.text = truncate(
|
if (row.field) {
|
||||||
item.label,
|
row.field.visible = number !== null;
|
||||||
row.label.style,
|
if (number !== null) row.field.setNumber(number);
|
||||||
Math.max(0, this.w - textX - hintW - eyeW - crossW - PAD),
|
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, nameRight - textX - hintW - PAD));
|
||||||
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
|
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
|
||||||
|
|
||||||
|
const mid = SORTABLE_ROW_HEIGHT / 2;
|
||||||
row.eye.clear();
|
row.eye.clear();
|
||||||
if (eyes) drawEye(row.eye, this.w - crossW - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden);
|
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.cross.clear();
|
row.cross.clear();
|
||||||
if (crosses) {
|
if (crosses && active) {
|
||||||
const hot = hovered?.row === index && hovered.cross;
|
const hot = underPointer && hovered.part === 'cross';
|
||||||
drawCross(row.cross, this.w - REMOVE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, hot);
|
drawCross(row.cross, slots.cross - REMOVE_SLOT / 2, mid, hot);
|
||||||
}
|
}
|
||||||
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
|
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) {
|
if (this.drag?.active) {
|
||||||
const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
|
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);
|
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 {
|
function sameHover(a: Hover, b: Hover): boolean {
|
||||||
if (a === null || b === null || a === 'add' || b === 'add') return a === b;
|
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<string, number>();
|
||||||
|
|
||||||
|
/** 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. */
|
/** 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 });
|
.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. */
|
/** 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 {
|
function drawCross(g: Graphics, x: number, y: number, hot: boolean): void {
|
||||||
g.moveTo(x - 4, y - 4)
|
g.moveTo(x - 4, y - 4)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
withFrameRemoved,
|
withFrameRemoved,
|
||||||
withLayerAdded,
|
withLayerAdded,
|
||||||
withLayerChanged,
|
withLayerChanged,
|
||||||
|
withLayerDuplicated,
|
||||||
withLayerMergedDown,
|
withLayerMergedDown,
|
||||||
withLayerMoved,
|
withLayerMoved,
|
||||||
withLayerRemoved,
|
withLayerRemoved,
|
||||||
@@ -361,6 +362,25 @@ describe('edits', () => {
|
|||||||
expect(() => withLayerMergedDown(s, layer, {})).toThrow('no layer below');
|
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', () => {
|
it('adds a frame empty or as a copy, moves it and keeps the last one', () => {
|
||||||
const { asset, layer, frame } = start();
|
const { asset, layer, frame } = start();
|
||||||
let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]);
|
let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]);
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
|
import { AddTilePictureCommand } from '../../src/core/commands/tileCommands';
|
||||||
import {
|
import {
|
||||||
AddTilemapLayerCommand,
|
AddTilemapLayerCommand,
|
||||||
|
DuplicateTilemapLayerCommand,
|
||||||
MoveTilemapLayerCommand,
|
MoveTilemapLayerCommand,
|
||||||
PaintTilesCommand,
|
PaintTilesCommand,
|
||||||
RemoveTilemapLayerCommand,
|
RemoveTilemapLayerCommand,
|
||||||
RenameTilemapLayerCommand,
|
RenameTilemapLayerCommand,
|
||||||
SetTilemapLayerHiddenCommand,
|
SetTilemapLayerHiddenCommand,
|
||||||
|
SetTilemapLayerZCommand,
|
||||||
} from '../../src/core/commands/tilemapCommands';
|
} from '../../src/core/commands/tilemapCommands';
|
||||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
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']);
|
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', () => {
|
it('renames a layer without the spaces around it, refuses a blank name and undoes to the old one', () => {
|
||||||
const f = makeTilemap();
|
const f = makeTilemap();
|
||||||
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
|
f.stack.execute(new RenameTilemapLayerCommand(f.tilemapId, f.layerId(), ' Ground '));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
|
|||||||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||||
import {
|
import {
|
||||||
AddVectorShapeCommand,
|
AddVectorShapeCommand,
|
||||||
|
DuplicateVectorShapeCommand,
|
||||||
MoveVectorShapeCommand,
|
MoveVectorShapeCommand,
|
||||||
RemoveVectorShapeCommand,
|
RemoveVectorShapeCommand,
|
||||||
RenameVectorShapeCommand,
|
RenameVectorShapeCommand,
|
||||||
@@ -231,6 +232,29 @@ describe('editing the shapes', () => {
|
|||||||
expect(f.art().shapes).toEqual([]);
|
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', () => {
|
it('keeps the drawing order apart from the list order in no way at all', () => {
|
||||||
const f = drawing();
|
const f = drawing();
|
||||||
const ids = ['a', 'b', 'c'].map(() => {
|
const ids = ['a', 'b', 'c'].map(() => {
|
||||||
|
|||||||
+34
-13
@@ -463,31 +463,33 @@ export async function tilemap(t) {
|
|||||||
await undo();
|
await undo();
|
||||||
await settle();
|
await settle();
|
||||||
|
|
||||||
// 40m. The painted layer is named in the field below the list, and Show singles it out in the
|
// 40m. A double-click on the painted layer's name edits it in its row, and Show singles it out in
|
||||||
// scene view (#138). It sits here, not at the end, because here both layers exist: the upper
|
// the scene view (#138). It sits here, not at the end, because here both layers exist: the
|
||||||
// one is painted on and holds the cell (4,0), the lower one the 3 × 3 block.
|
// 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()');
|
const nameSpot = await inPanel(t, '__gm.tilemap.layerNameAt()');
|
||||||
if (nameField) {
|
if (nameSpot) await t.doubleClick(nameSpot.x, nameSpot.y);
|
||||||
await click(nameField.x, nameField.y);
|
await settle();
|
||||||
|
const nameEditing = (await json('__gm.tilemap.layerNameAt()'))?.editing;
|
||||||
await press('a', { ctrl: true });
|
await press('a', { ctrl: true });
|
||||||
await typeText('Roofs');
|
await typeText('Roofs');
|
||||||
await press('Enter');
|
await press('Enter');
|
||||||
}
|
|
||||||
await settle();
|
await settle();
|
||||||
layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
layers = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||||||
const renamedRow = await json('(__gm.tilemap.layerRowAt(0) || {}).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(
|
check(
|
||||||
'the name field renames the layer painted on, and the list shows the new name',
|
'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[1].name === 'Roofs' &&
|
||||||
layers[0].name === 'Layer 1' &&
|
layers[0].name === 'Layer 1' &&
|
||||||
renamedRow === layers[1].id &&
|
renamedRow === layers[1].id &&
|
||||||
nameNow === 'Roofs',
|
nameNow?.value === 'Roofs' &&
|
||||||
JSON.stringify({ layers, renamedRow, nameNow }),
|
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
|
// 40p. The Z field in the row gives the painted layer a z of its own, added to the map's (#174):
|
||||||
// (#174): at −5 the upper layer is sorted in below the map itself and below the lower layer.
|
// 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()');
|
const zField = await inPanel(t, '__gm.tilemap.layerZAt()');
|
||||||
if (zField) {
|
if (zField) {
|
||||||
await click(zField.x, zField.y);
|
await click(zField.x, zField.y);
|
||||||
@@ -519,6 +521,25 @@ export async function tilemap(t) {
|
|||||||
JSON.stringify({ withZ, orderZ, orderBack, zBack }),
|
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.
|
// 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 mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
|
||||||
await settle();
|
await settle();
|
||||||
|
|||||||
@@ -385,6 +385,51 @@ export async function vectorArt(t) {
|
|||||||
JSON.stringify({ cross, afterCross, selected: await json('__gm.vectorArt.selected()') }),
|
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
|
// 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
|
// 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.
|
// Texture row – the picture picker of #185, which until here only unit tests had seen.
|
||||||
|
|||||||
@@ -233,10 +233,11 @@ export async function pixelArt(t) {
|
|||||||
const hiddenFlag = (await asset(id)).layers.at(-1).hidden;
|
const hiddenFlag = (await asset(id)).layers.at(-1).hidden;
|
||||||
await click(eyeAt.x, eyeAt.y);
|
await click(eyeAt.x, eyeAt.y);
|
||||||
await settle();
|
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 click(opacity.x, opacity.y);
|
||||||
await press('a', { ctrl: true });
|
await press('a', { ctrl: true });
|
||||||
await typeText('0.5');
|
await typeText('50');
|
||||||
await press('Enter');
|
await press('Enter');
|
||||||
await settle();
|
await settle();
|
||||||
const half = await screen(10, 10);
|
const half = await screen(10, 10);
|
||||||
@@ -256,7 +257,7 @@ export async function pixelArt(t) {
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
check(
|
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 &&
|
colourDistance(blueShown, HEX[BLUE]) < 10 &&
|
||||||
hiddenFlag === true &&
|
hiddenFlag === true &&
|
||||||
colourDistance(hidden, HEX[GREEN]) < 10 &&
|
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 }),
|
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
|
// 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.
|
// another order. The picture's centre is canvas pixel (16, 16): green below, empty above.
|
||||||
const bottom = art.layers[0].id;
|
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.
|
// 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);
|
art = await asset(id);
|
||||||
const second = await json('__gm.pixelArt.frameId()');
|
const second = await json('__gm.pixelArt.frameId()');
|
||||||
check(
|
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.length === 2 &&
|
||||||
art.frames[1].id === second &&
|
art.frames[1].id === second &&
|
||||||
JSON.stringify(art.frames[1].cels) === JSON.stringify(art.frames[0].cels) &&
|
JSON.stringify(art.frames[1].cels) === JSON.stringify(art.frames[0].cels) &&
|
||||||
|
|||||||
Reference in new issue
Block a user