Put the shapes of a vector drawing on layers and in groups (#190)
A vector drawing was one flat list of shapes. With many shapes the list got hard to read, and several shapes could not be kept or moved together. Now the shapes stand on layers, as in pixel art, and can be put into groups that nest. - A drawing has layers, each with a name and an eye. A hidden layer is neither in the tab nor in the picture a sprite wears, and its shapes cannot be pressed. One layer is the one worked on: the pen and Add shape draw onto it, and SHAPES lists what is on it. - The inspector has a list LAYERS over the shapes. Its plus adds a layer, the cross on a row removes it with all on it, the last one stays. A double-click renames, a drag sorts. - Group (Ctrl+G) puts the selected shapes into a group, Ungroup (Ctrl+Shift+G) takes it away. A group stands over its rows in the list, the rows one step in, and a row is dragged into a group or out of it. - In the tab a press on a shape takes its outermost group, and a drag moves the group as one undo step. - The model has layers with a tree of shapes and groups instead of a flat shapes array. The rules are pure functions in core/model/vectorTree.ts. A drawing saved before opens with all its shapes on one layer. - SortableList takes rows with a depth and drops into a tree. The flat lists of tilemap and pixel art run as before. Decided here: - A group has no transformation of its own and cannot be hidden or locked. Rotating and scaling is the follow-up #291. - A group is never empty: the one that loses its last shape goes with it. - Several selected shapes are dragged together, grouped or not, and a click on a shape of the selection keeps the selection. This is what #217 asked for. - Shift+click selects like Ctrl+click. With Select, Shift pans only beside the shapes, as in the scene view. The middle button pans anywhere, and with the pen and Paint Shift pans as before. - A row of the list picks one shape of a group alone. - Shapes change their layer through a row Layer in the inspector, which is there from two layers on. Rows are not dragged between layers. - A group cannot span two layers, the button is off then. - The pen does not draw onto a hidden layer, the hint says so. - Groups are always shown unfolded. - The four order buttons work inside the own group or layer. Tests: - Gate: 204 files, 3067 tests (3008 + 59), exit 0 - e2e 47,48: 108 checks. e2e 39,40,47,48 before the Shift change: 209 checks. No full run, the sum of 1645 (1639 + 6) is reckoned and CI measures it. - 59 unit tests: 36 in tests/core/vector-tree.test.ts, 23 in tests/editor/vector-layers.test.ts - Six e2e checks with a real mouse in block 47j - 47 unit counter-probes for layers and groups and three for the Shift click all fail tests, run on stein2 - Three browser counter-probes fail their checks - Two screenshots looked at (group in the list, hidden layer) - Only unit tested: the row Layer, sorting and removing layers, copying a group, the Shift pan beside the shapes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
393caadc9d
commit
2ff3a78607
30 files changed
+2625
-408
No files matched your search
@@ -46,6 +46,8 @@ const REMOVE_SLOT = 24;
|
||||
const VALUE_WIDTH = 44;
|
||||
/** Height of a header that only holds the plus; one with a title is a band (see `sections.ts`). */
|
||||
const HEADER_HEIGHT = 24;
|
||||
/** How far a row stands in for each group it is inside of. */
|
||||
export const SORTABLE_INDENT = 14;
|
||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
|
||||
@@ -56,6 +58,8 @@ export interface SortableItem {
|
||||
hint?: string;
|
||||
/** Drawn faded, with a closed eye. */
|
||||
hidden?: boolean;
|
||||
/** How many groups the item is inside of, in a list that shows a tree; its row stands in that far. */
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
/** Part of the picture square that is shown, in its own coordinates. */
|
||||
@@ -94,6 +98,14 @@ export interface SortableListOptions {
|
||||
onPick: (id: string, toggle: boolean) => void;
|
||||
/** A row dropped somewhere else, in the owner's indices. */
|
||||
onMove: (from: number, to: number) => void;
|
||||
/**
|
||||
* For a list that shows a tree: a drop is reported here instead of to `onMove`, as the gap between
|
||||
* the rows it fell into (0 is above the first) and the depth the pointer asked for – the dragged
|
||||
* row's own, plus one for every indent the pointer went right.
|
||||
*/
|
||||
onDrop?: (id: string, gap: number, depth: number) => void;
|
||||
/** With `onDrop`: the depth that drop would land at, for the marker; null where it cannot land. */
|
||||
dropDepth?: (id: string, gap: number, depth: number) => number | null;
|
||||
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */
|
||||
onToggleHidden?: (id: string) => void;
|
||||
/** Setting this gives every row a duplicate mark left of the cross; a press on it reports the item. */
|
||||
@@ -139,6 +151,8 @@ interface RowDrag {
|
||||
/** Both in the rows' own coordinates, below the header. */
|
||||
startY: number;
|
||||
y: number;
|
||||
startX: number;
|
||||
x: number;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
@@ -269,14 +283,14 @@ export class SortableList extends Widget implements SectionStart {
|
||||
thumbGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
if (!row || !this.options.thumb) return null;
|
||||
return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) };
|
||||
return { x: Math.round(row.x + this.indentOf(id) + 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) };
|
||||
return { x: Math.round(row.x + this.indentOf(id) + 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. */
|
||||
@@ -328,11 +342,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1);
|
||||
}
|
||||
|
||||
/** Where the name starts. */
|
||||
/** Where the name starts in a row that is in no group. */
|
||||
private get textX(): number {
|
||||
return this.options.thumb ? PAD * 2 + THUMB : PAD * 2;
|
||||
}
|
||||
|
||||
private indentOf(id: string): number {
|
||||
return (this.shown.find((item) => item.id === id)?.depth ?? 0) * SORTABLE_INDENT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -404,15 +422,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
}
|
||||
const row = this.rowAt(local.y);
|
||||
if (row < 0) return null;
|
||||
return { row, part: this.partAt(local.x) };
|
||||
return { row, part: this.partAt(local.x, (this.shown[row]?.depth ?? 0) * SORTABLE_INDENT) };
|
||||
}
|
||||
|
||||
private partAt(x: number): RowPart {
|
||||
private partAt(x: number, indent: 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';
|
||||
if (this.options.onRename && x >= indent + this.textX && x < this.nameRight) return 'name';
|
||||
return 'row';
|
||||
}
|
||||
|
||||
@@ -456,7 +474,7 @@ export class SortableList extends Widget implements SectionStart {
|
||||
// Picked on release: a pick rebuilds what the list stands in, which would end the drag.
|
||||
const pick = this.selected.length !== 1 || this.selected[0] !== id;
|
||||
const y = local.y - this.headerHeight;
|
||||
this.drag = { from: hit.row, pick, startY: y, y, active: false };
|
||||
this.drag = { from: hit.row, pick, startY: y, y, startX: local.x, x: local.x, active: false };
|
||||
}
|
||||
|
||||
private startRename(index: number): void {
|
||||
@@ -488,7 +506,7 @@ export class SortableList extends Widget implements SectionStart {
|
||||
const renaming = this.renaming;
|
||||
if (!renaming) return;
|
||||
const index = this.shown.findIndex((item) => item.id === renaming.id);
|
||||
const x = this.textX - PAD;
|
||||
const x = this.indentOf(renaming.id) + this.textX - PAD;
|
||||
renaming.field.position.set(
|
||||
x,
|
||||
this.headerHeight + index * SORTABLE_ROW_HEIGHT + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2,
|
||||
@@ -499,8 +517,14 @@ export class SortableList extends Widget implements SectionStart {
|
||||
private onMove(e: FederatedPointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return;
|
||||
drag.y = this.toLocal(e.global).y - this.headerHeight;
|
||||
if (!drag.active && Math.abs(drag.y - drag.startY) < DRAG_THRESHOLD) return;
|
||||
const local = this.toLocal(e.global);
|
||||
drag.y = local.y - this.headerHeight;
|
||||
drag.x = local.x;
|
||||
const far = Math.max(
|
||||
Math.abs(drag.y - drag.startY),
|
||||
this.options.onDrop ? Math.abs(drag.x - drag.startX) : 0,
|
||||
);
|
||||
if (!drag.active && far < DRAG_THRESHOLD) return;
|
||||
drag.active = true;
|
||||
this.invalidate();
|
||||
}
|
||||
@@ -513,12 +537,22 @@ export class SortableList extends Widget implements SectionStart {
|
||||
const id = this.shown[drag.from]?.id;
|
||||
if (drag.active) {
|
||||
const gap = dropGap(this.shown.length, drag.y, SORTABLE_ROW_HEIGHT);
|
||||
const move = dropMove(this.shown.length, drag.from, gap, this.options.reversed ?? false);
|
||||
if (move) this.options.onMove(move.from, move.to);
|
||||
if (this.options.onDrop) {
|
||||
if (id) this.options.onDrop(id, gap, this.askedDepth(drag));
|
||||
} else {
|
||||
const move = dropMove(this.shown.length, drag.from, gap, this.options.reversed ?? false);
|
||||
if (move) this.options.onMove(move.from, move.to);
|
||||
}
|
||||
}
|
||||
if (drag.pick && id) this.options.onPick(id, false);
|
||||
}
|
||||
|
||||
/** The depth a drag asks for: the row's own, one more for every indent the pointer went right. */
|
||||
private askedDepth(drag: RowDrag): number {
|
||||
const own = this.shown[drag.from]?.depth ?? 0;
|
||||
return own + Math.round((drag.x - drag.startX) / SORTABLE_INDENT);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const top = this.headerHeight;
|
||||
this.background.clear();
|
||||
@@ -550,14 +584,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
|
||||
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
|
||||
|
||||
const indent = (item.depth ?? 0) * SORTABLE_INDENT;
|
||||
const thumbY = (SORTABLE_ROW_HEIGHT - THUMB) / 2;
|
||||
row.art.clear();
|
||||
row.art.position.set(PAD, thumbY);
|
||||
row.art.position.set(indent + PAD, thumbY);
|
||||
row.art.scale.set(1);
|
||||
row.clip.clear();
|
||||
if (thumbs) {
|
||||
const clip = thumbs(item.id, row.art, THUMB) ?? { x: 0, y: 0, w: THUMB, h: THUMB };
|
||||
row.clip.rect(PAD + clip.x, thumbY + clip.y, clip.w, clip.h).fill(0xffffff);
|
||||
row.clip.rect(indent + PAD + clip.x, thumbY + clip.y, clip.w, clip.h).fill(0xffffff);
|
||||
}
|
||||
row.art.alpha = item.hidden ? 0.35 : 1;
|
||||
|
||||
@@ -583,8 +618,12 @@ export class SortableList extends Widget implements SectionStart {
|
||||
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.text = truncate(
|
||||
item.label,
|
||||
row.label.style,
|
||||
Math.max(0, nameRight - indent - textX - hintW - PAD),
|
||||
);
|
||||
row.label.position.set(indent + textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
|
||||
|
||||
const mid = SORTABLE_ROW_HEIGHT / 2;
|
||||
row.eye.clear();
|
||||
@@ -607,8 +646,15 @@ export class SortableList extends Widget implements SectionStart {
|
||||
}
|
||||
this.placeRenameField();
|
||||
if (this.drag?.active) {
|
||||
const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
|
||||
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
|
||||
const gap = dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT);
|
||||
const y = top + gap * SORTABLE_ROW_HEIGHT;
|
||||
const id = this.shown[this.drag.from]?.id ?? '';
|
||||
// In a tree the marker starts where the row would: that is how one sees which group it joins.
|
||||
const depth = this.options.dropDepth ? this.options.dropDepth(id, gap, this.askedDepth(this.drag)) : 0;
|
||||
if (depth !== null) {
|
||||
const x = depth * SORTABLE_INDENT;
|
||||
this.marker.rect(x, Math.min(y, this.h - 2), this.w - x, 2).fill(Theme.colors.accent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user