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
+747
-153
No files matched your search
@@ -35,6 +35,7 @@ import {
|
||||
addFrame,
|
||||
addLayer,
|
||||
addPaletteColor,
|
||||
duplicateLayer,
|
||||
mergeLayerDown,
|
||||
moveFrame,
|
||||
moveLayer,
|
||||
@@ -69,12 +70,11 @@ export class PixelArtSide extends ScrollContainer {
|
||||
readonly removeColorButton = new Button('Remove last');
|
||||
readonly createPaletteButton = new Button('Create palette');
|
||||
private readonly paletteNote = new Label('', { style: 'muted' });
|
||||
/** Each row names its layer (a double-click edits it), holds its opacity in percent and duplicates it. */
|
||||
readonly layers: SortableList;
|
||||
readonly layerName = new TextField('');
|
||||
readonly layerOpacity = new NumberField(1, { min: 0, max: 1, step: 0.05, decimals: 2 });
|
||||
readonly layerButtons: Record<'merge', Button>;
|
||||
/** Each row duplicates its frame right after it. */
|
||||
readonly frames: SortableList;
|
||||
readonly frameButtons: Record<'duplicate', Button>;
|
||||
private readonly thumbs = new PixelThumbs();
|
||||
private readonly stopCels: () => void;
|
||||
readonly onionBox = new Checkbox(false);
|
||||
@@ -137,16 +137,25 @@ export class PixelArtSide extends ScrollContainer {
|
||||
this.withAsset((a) => a.layers[from] && moveLayer(state, a, a.layers[from].id, to)),
|
||||
onToggleHidden: (id) => this.toggleHidden(id),
|
||||
onAdd: () => this.addLayer(),
|
||||
onDuplicate: (id) => this.duplicateLayer(id),
|
||||
onRemove: (id) => this.withAsset((a) => removeLayer(state, a, id)),
|
||||
onRename: (id, name) => this.withAsset((a) => renameLayer(state, a, id, name)),
|
||||
value: {
|
||||
get: (id) => {
|
||||
const layer = this.canvas.asset()?.layers.find((l) => l.id === id);
|
||||
return layer ? Math.round(layer.opacity * 100) : null;
|
||||
},
|
||||
preview: (id, percent) => this.previewOpacity(id, percent / 100),
|
||||
set: (id, percent) => this.commitOpacity(id, percent / 100),
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
decimals: 0,
|
||||
suffix: '%',
|
||||
},
|
||||
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, id)),
|
||||
});
|
||||
c.add(this.layers);
|
||||
this.layerName.onChange = (name) =>
|
||||
this.withAsset((asset) => renameLayer(state, asset, this.canvas.layerId, name));
|
||||
this.layerOpacity.onNumberChange = (value) =>
|
||||
this.withAsset((asset) => setLayerOpacity(state, asset, this.canvas.layerId, value));
|
||||
c.add(row(new Label('Name', { style: 'muted' }), this.layerName));
|
||||
c.add(row(new Label('Opacity', { style: 'muted' }), this.layerOpacity));
|
||||
this.layerButtons = {
|
||||
merge: new Button('Merge down', {
|
||||
onClick: () => this.withAsset((a) => mergeLayerDown(state, a, this.canvas.layerId)),
|
||||
@@ -160,15 +169,12 @@ export class PixelArtSide extends ScrollContainer {
|
||||
onPick: (id) => this.canvas.selectFrame(id),
|
||||
onMove: (from, to) =>
|
||||
this.withAsset((a) => a.frames[from] && moveFrame(state, a, a.frames[from].id, to)),
|
||||
onAdd: () => this.addFrame(false),
|
||||
onAdd: () => this.addFrame(this.canvas.frameId, false),
|
||||
onDuplicate: (id) => this.addFrame(id, true),
|
||||
onRemove: (id) => this.withAsset((a) => removeFrame(state, a, id)),
|
||||
thumb: (id, art, size) => this.withAsset((a) => this.paintThumb(a, art, size, undefined, id)),
|
||||
});
|
||||
c.add(this.frames);
|
||||
this.frameButtons = {
|
||||
duplicate: new Button('Duplicate', { onClick: () => this.addFrame(true) }),
|
||||
};
|
||||
c.add(row(this.frameButtons.duplicate));
|
||||
|
||||
c.add(heading('View'));
|
||||
this.onionBox.onChange = (on) => {
|
||||
@@ -296,6 +302,36 @@ export class PixelArtSide extends ScrollContainer {
|
||||
if (id) this.canvas.selectLayer(id);
|
||||
}
|
||||
|
||||
private duplicateLayer(layerId: string): void {
|
||||
const asset = this.canvas.asset();
|
||||
if (!asset) return;
|
||||
const id = duplicateLayer(this.state, asset, layerId);
|
||||
if (id) this.canvas.selectLayer(id);
|
||||
}
|
||||
|
||||
/** The opacity a scrub started from, per layer – what the commit puts back first, as with a colour. */
|
||||
private readonly opacityBefore = new Map<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 {
|
||||
const asset = this.canvas.asset();
|
||||
const layer = asset?.layers.find((l) => l.id === layerId);
|
||||
@@ -314,10 +350,11 @@ export class PixelArtSide extends ScrollContainer {
|
||||
if (frame) this.thumbs.paint(art, size, asset, paletteColors(this.state.project, asset), frame, layerId);
|
||||
}
|
||||
|
||||
private addFrame(copy: boolean): void {
|
||||
/** A frame after `afterId`: empty, or a copy of it. */
|
||||
private addFrame(afterId: string, copy: boolean): void {
|
||||
const asset = this.canvas.asset();
|
||||
if (!asset) return;
|
||||
const id = addFrame(this.state, asset, this.canvas.frameId, copy);
|
||||
const id = addFrame(this.state, asset, afterId, copy);
|
||||
if (id) this.canvas.selectFrame(id);
|
||||
}
|
||||
|
||||
@@ -341,18 +378,10 @@ export class PixelArtSide extends ScrollContainer {
|
||||
|
||||
const layerId = this.canvas.layerId;
|
||||
this.layers.setItems(
|
||||
(asset?.layers ?? []).map((layer) => ({
|
||||
id: layer.id,
|
||||
label: layer.name,
|
||||
hint: layer.opacity < 1 ? `${Math.round(layer.opacity * 100)}%` : undefined,
|
||||
hidden: layer.hidden,
|
||||
})),
|
||||
(asset?.layers ?? []).map((layer) => ({ id: layer.id, label: layer.name, hidden: layer.hidden })),
|
||||
layerId ? [layerId] : [],
|
||||
);
|
||||
const layer = asset?.layers.find((l) => l.id === layerId);
|
||||
this.layerName.setValue(layer?.name ?? '');
|
||||
this.layerOpacity.setNumber(layer?.opacity ?? 1);
|
||||
const index = asset && layer ? asset.layers.indexOf(layer) : -1;
|
||||
const index = asset ? asset.layers.findIndex((l) => l.id === layerId) : -1;
|
||||
this.layerButtons.merge.setEnabled(index > 0);
|
||||
|
||||
const frameId = this.canvas.frameId;
|
||||
@@ -360,8 +389,6 @@ export class PixelArtSide extends ScrollContainer {
|
||||
(asset?.frames ?? []).map((frame, i) => ({ id: frame.id, label: frameLabel(i) })),
|
||||
frameId ? [frameId] : [],
|
||||
);
|
||||
const frameIndex = asset ? asset.frames.findIndex((f) => f.id === frameId) : -1;
|
||||
this.frameButtons.duplicate.setEnabled(frameIndex >= 0);
|
||||
this.relayoutContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
withFrameRemoved,
|
||||
withLayerAdded,
|
||||
withLayerChanged,
|
||||
withLayerDuplicated,
|
||||
withLayerMergedDown,
|
||||
withLayerMoved,
|
||||
withLayerRemoved,
|
||||
@@ -52,6 +53,12 @@ export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: stri
|
||||
return edit(state, asset, 'Add layer', (s) => withLayerAdded(s, id, aboveId)) ? id : '';
|
||||
}
|
||||
|
||||
/** A copy of the layer right above it; its id, so the tab can go on painting on the copy. */
|
||||
export function duplicateLayer(state: EditorState, asset: PixelArtAsset, layerId: string): string {
|
||||
const id = newId();
|
||||
return edit(state, asset, 'Duplicate layer', (s) => withLayerDuplicated(s, id, layerId)) ? id : '';
|
||||
}
|
||||
|
||||
export function removeLayer(state: EditorState, asset: PixelArtAsset, layerId: string): boolean {
|
||||
return edit(state, asset, 'Delete layer', (s) => withLayerRemoved(s, layerId));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user