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
@@ -36,6 +36,7 @@ export {
|
||||
SortableList,
|
||||
type SortableItem,
|
||||
type SortableListOptions,
|
||||
type SortableValue,
|
||||
type ThumbClip,
|
||||
} from './widgets/SortableList';
|
||||
export { dropGap, dropMove, rowOrder } from './sortable';
|
||||
|
||||
+267
-37
@@ -1,26 +1,41 @@
|
||||
/**
|
||||
* A list whose rows are dragged into another order – the layer list of every editor that has one:
|
||||
* the shapes of vector art, the layers and frames of pixel art, the layers of a tilemap. Each row may
|
||||
* carry a small picture the owner paints, an eye that hides the item without removing it and a cross
|
||||
* that removes it. Over the rows a header names the list and holds the plus that adds an item.
|
||||
* carry a small picture the owner paints, a number typed or scrubbed in place, an eye that hides the
|
||||
* item without removing it, a mark that duplicates it and a cross that removes it. A double-click on
|
||||
* the name edits it in the row. Over the rows a header names the list and holds the plus that adds
|
||||
* an item.
|
||||
*
|
||||
* The list never changes its items itself. A drop, a cross or the plus reports to the owner, the
|
||||
* owner runs its command, and the next `setItems` shows the result.
|
||||
* The duplicate mark and the cross only show on the row under the pointer and on the marked ones, so
|
||||
* a long list stays quiet; their places are kept free on every row, so nothing moves when they show.
|
||||
*
|
||||
* The list never changes its items itself. A drop, a cross, a name or a number reports to the owner,
|
||||
* the owner runs its command, and the next `setItems` shows the result.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { pressCount } from '../clicks';
|
||||
import { dropGap, dropMove, rowOrder } from '../sortable';
|
||||
import { Theme, textStyle } from '../Theme';
|
||||
import { ui } from '../UIContext';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
import { truncate } from './Label';
|
||||
import { NumberField } from './NumberField';
|
||||
import { TextField } from './TextField';
|
||||
|
||||
/** Height of one row: the picture plus the air a hovered row needs to look like a row. */
|
||||
export const SORTABLE_ROW_HEIGHT = 30;
|
||||
const THUMB = 22;
|
||||
const PAD = 4;
|
||||
/** Height of the fields that stand in a row: the name being edited and the number. */
|
||||
const FIELD_HEIGHT = 22;
|
||||
/** Width at the right end of a row that belongs to the eye. */
|
||||
const EYE_SLOT = 26;
|
||||
/** Width left of the cross that belongs to the duplicate mark. */
|
||||
const DUPLICATE_SLOT = 22;
|
||||
/** Width at the very right end of a row that belongs to the cross, and of the header to the plus. */
|
||||
const REMOVE_SLOT = 24;
|
||||
/** Width of the number field, where the rows have one. */
|
||||
const VALUE_WIDTH = 44;
|
||||
/** Height of the header, where there is one. */
|
||||
const HEADER_HEIGHT = 24;
|
||||
/** Pixels the pointer must travel before a press on a row becomes a drag rather than a pick. */
|
||||
@@ -43,6 +58,22 @@ export interface ThumbClip {
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A number each row shows in a field of its own – a layer's opacity, its z. */
|
||||
export interface SortableValue {
|
||||
/** Null leaves the row without a field. */
|
||||
get: (id: string) => number | null;
|
||||
/** Once, when a scrub ends or a typed number is committed. */
|
||||
set: (id: string, value: number) => void;
|
||||
/** While the field is scrubbed, for an owner that shows the number before it commits it. */
|
||||
preview?: (id: string, value: number) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
decimals?: number;
|
||||
/** Small text right of the field, such as "%". */
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
export interface SortableListOptions {
|
||||
/** The model's last item is the topmost row, as in every layer list: it is drawn last. */
|
||||
reversed?: boolean;
|
||||
@@ -57,10 +88,15 @@ export interface SortableListOptions {
|
||||
onMove: (from: number, to: number) => void;
|
||||
/** Setting this gives every row an eye; a press on it reports the item and nothing else. */
|
||||
onToggleHidden?: (id: string) => void;
|
||||
/** Setting this gives every row a duplicate mark left of the cross; a press on it reports the item. */
|
||||
onDuplicate?: (id: string) => void;
|
||||
/** Setting this gives every row a cross at its right end; a press on it reports the item. */
|
||||
onRemove?: (id: string) => void;
|
||||
/** No cross while a single item is left – a map or a picture needs one layer to paint on. */
|
||||
keepOne?: boolean;
|
||||
/** Setting this makes a double-click on a name edit it in the row; a blank or unchanged name is not reported. */
|
||||
onRename?: (id: string, name: string) => void;
|
||||
value?: SortableValue;
|
||||
/** Stands in the header, left. */
|
||||
title?: string;
|
||||
/** Setting this puts a plus at the right end of the header. */
|
||||
@@ -75,7 +111,10 @@ interface Row {
|
||||
label: Text;
|
||||
hint: Text;
|
||||
eye: Graphics;
|
||||
duplicate: Graphics;
|
||||
cross: Graphics;
|
||||
/** Only with `value`; a child of the list, not of `box`, so it takes the pointer. */
|
||||
field: NumberField | null;
|
||||
}
|
||||
|
||||
interface RowDrag {
|
||||
@@ -89,8 +128,21 @@ interface RowDrag {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** What the pointer is over: a row, a row's cross, or the plus. */
|
||||
type Hover = { row: number; cross: boolean } | 'add' | null;
|
||||
/** Which part of a row a point is on. */
|
||||
type RowPart = 'row' | 'eye' | 'duplicate' | 'cross' | 'name';
|
||||
|
||||
/** What the pointer is over: a part of a row, or the plus. */
|
||||
type Hover = { row: number; part: RowPart } | 'add' | null;
|
||||
|
||||
/** A name edited in its row: it goes away as soon as it loses the focus. */
|
||||
class RowNameField extends TextField {
|
||||
onDone: () => void = () => {};
|
||||
|
||||
override onBlur(): void {
|
||||
super.onBlur();
|
||||
this.onDone();
|
||||
}
|
||||
}
|
||||
|
||||
export class SortableList extends Widget {
|
||||
private readonly background = new Graphics();
|
||||
@@ -103,6 +155,8 @@ export class SortableList extends Widget {
|
||||
private selected: readonly string[] = [];
|
||||
private over: Hover = null;
|
||||
private drag: RowDrag | null = null;
|
||||
/** The name being edited in its row, and whose it is. */
|
||||
private renaming: { id: string; field: RowNameField } | null = null;
|
||||
|
||||
constructor(private readonly options: SortableListOptions) {
|
||||
super();
|
||||
@@ -138,8 +192,13 @@ export class SortableList extends Widget {
|
||||
this.shown = rowOrder(items, this.options.reversed ?? false);
|
||||
this.selected = selectedIds;
|
||||
while (this.rows.length < this.shown.length) this.rows.push(this.buildRow());
|
||||
while (this.rows.length > this.shown.length) this.rows.pop()!.box.destroy({ children: true });
|
||||
while (this.rows.length > this.shown.length) {
|
||||
const row = this.rows.pop()!;
|
||||
row.box.destroy({ children: true });
|
||||
row.field?.destroy();
|
||||
}
|
||||
if (countChanged) this.drag = null;
|
||||
if (this.renaming && !this.shown.some((item) => item.id === this.renaming?.id)) ui().focus.blur();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
@@ -173,11 +232,25 @@ export class SortableList extends Widget {
|
||||
return { x: Math.round(row.x + PAD + THUMB / 2), y: Math.round(row.y + row.h / 2) };
|
||||
}
|
||||
|
||||
/** A point on an item's name, where a double-click edits it (test hook); null without that row. */
|
||||
nameGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
if (!row) return null;
|
||||
return { x: Math.round(row.x + this.textX + 12), y: Math.round(row.y + row.h / 2) };
|
||||
}
|
||||
|
||||
/** The centre of an item's eye (test hook); null without eyes or without that row. */
|
||||
eyeGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
if (!row || !this.options.onToggleHidden) return null;
|
||||
return { x: row.x + row.w - this.crossWidth - EYE_SLOT / 2, y: row.y + row.h / 2 };
|
||||
return { x: row.x + this.slots.eye - EYE_SLOT / 2, y: row.y + row.h / 2 };
|
||||
}
|
||||
|
||||
/** The centre of an item's duplicate mark (test hook); null without marks or without that row. */
|
||||
duplicateGlobal(id: string): { x: number; y: number } | null {
|
||||
const row = this.rowGlobal(id);
|
||||
if (!row || !this.options.onDuplicate) return null;
|
||||
return { x: row.x + this.slots.duplicate - DUPLICATE_SLOT / 2, y: row.y + row.h / 2 };
|
||||
}
|
||||
|
||||
/** The centre of an item's cross (test hook); null where the row has none. */
|
||||
@@ -187,6 +260,18 @@ export class SortableList extends Widget {
|
||||
return { x: row.x + row.w - REMOVE_SLOT / 2, y: row.y + row.h / 2 };
|
||||
}
|
||||
|
||||
/** The number field of an item's row (test hook); null where the row has none. */
|
||||
valueField(id: string): NumberField | null {
|
||||
const index = this.shown.findIndex((item) => item.id === id);
|
||||
const field = this.rows[index]?.field;
|
||||
return field?.visible ? field : null;
|
||||
}
|
||||
|
||||
/** The field a name is being edited in (test hook); null while no name is. */
|
||||
get renameField(): TextField | null {
|
||||
return this.renaming?.field ?? null;
|
||||
}
|
||||
|
||||
/** The centre of the plus (test hook); null without one. */
|
||||
addGlobal(): { x: number; y: number } | null {
|
||||
if (!this.options.onAdd) return null;
|
||||
@@ -203,9 +288,30 @@ export class SortableList extends Widget {
|
||||
return Boolean(this.options.onRemove) && !(this.options.keepOne && this.shown.length <= 1);
|
||||
}
|
||||
|
||||
/** Width the cross takes at the right end of every row, or none. */
|
||||
private get crossWidth(): number {
|
||||
return this.options.onRemove ? REMOVE_SLOT : 0;
|
||||
/** Where the name starts. */
|
||||
private get textX(): number {
|
||||
return this.options.thumb ? PAD * 2 + THUMB : PAD * 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* The right edge of each part of a row, from the right: the cross, the duplicate mark, the eye and
|
||||
* the number. A part a list does not have takes no room.
|
||||
*/
|
||||
private get slots(): { cross: number; duplicate: number; eye: number; value: number } {
|
||||
const cross = this.w;
|
||||
const duplicate = cross - (this.options.onRemove ? REMOVE_SLOT : 0);
|
||||
const eye = duplicate - (this.options.onDuplicate ? DUPLICATE_SLOT : 0);
|
||||
const value = eye - (this.options.onToggleHidden ? EYE_SLOT : 0);
|
||||
return { cross, duplicate, eye, value };
|
||||
}
|
||||
|
||||
/** Where the name has to end: before the number and its suffix, or before the eye. */
|
||||
private get nameRight(): number {
|
||||
const { value } = this.slots;
|
||||
const options = this.options.value;
|
||||
if (!options) return value;
|
||||
const suffix = options.suffix ? suffixWidth(options.suffix) + PAD : 0;
|
||||
return value - PAD - suffix - VALUE_WIDTH;
|
||||
}
|
||||
|
||||
private buildRow(): Row {
|
||||
@@ -215,12 +321,35 @@ export class SortableList extends Widget {
|
||||
const label = new Text({ text: '', style: textStyle('body') });
|
||||
const hint = new Text({ text: '', style: textStyle('small') });
|
||||
const eye = new Graphics();
|
||||
const duplicate = new Graphics();
|
||||
const cross = new Graphics();
|
||||
box.eventMode = 'none';
|
||||
box.addChild(art, clip, label, hint, eye, cross);
|
||||
box.addChild(art, clip, label, hint, eye, duplicate, cross);
|
||||
art.mask = clip;
|
||||
this.addChildAt(box, this.getChildIndex(this.marker));
|
||||
return { box, art, clip, label, hint, eye, cross };
|
||||
const row: Row = { box, art, clip, label, hint, eye, duplicate, cross, field: null };
|
||||
const value = this.options.value;
|
||||
if (value) {
|
||||
const field = new NumberField(0, {
|
||||
min: value.min,
|
||||
max: value.max,
|
||||
step: value.step,
|
||||
decimals: value.decimals,
|
||||
});
|
||||
// The row the field stands in now: a drop or a new item can give it another one.
|
||||
const idOf = () => this.shown[this.rows.indexOf(row)]?.id;
|
||||
field.onNumberInput = (n) => {
|
||||
const id = idOf();
|
||||
if (id) value.preview?.(id, n);
|
||||
};
|
||||
field.onNumberChange = (n) => {
|
||||
const id = idOf();
|
||||
if (id) value.set(id, n);
|
||||
};
|
||||
row.field = field;
|
||||
this.addChildAt(field, this.getChildIndex(this.marker));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
private rowAt(y: number): number {
|
||||
@@ -233,7 +362,16 @@ export class SortableList extends Widget {
|
||||
return this.options.onAdd && local.x >= this.w - REMOVE_SLOT ? 'add' : null;
|
||||
const row = this.rowAt(local.y);
|
||||
if (row < 0) return null;
|
||||
return { row, cross: this.canRemove && local.x >= this.w - REMOVE_SLOT };
|
||||
return { row, part: this.partAt(local.x) };
|
||||
}
|
||||
|
||||
private partAt(x: number): RowPart {
|
||||
const slots = this.slots;
|
||||
if (this.canRemove && x >= slots.duplicate) return 'cross';
|
||||
if (this.options.onDuplicate && x >= slots.eye && x < slots.duplicate) return 'duplicate';
|
||||
if (this.options.onToggleHidden && x >= slots.value && x < slots.eye) return 'eye';
|
||||
if (this.options.onRename && x >= this.textX && x < this.nameRight) return 'name';
|
||||
return 'row';
|
||||
}
|
||||
|
||||
private onPress(e: FederatedPointerEvent): void {
|
||||
@@ -247,13 +385,22 @@ export class SortableList extends Widget {
|
||||
return;
|
||||
}
|
||||
const id = this.shown[hit.row]!.id;
|
||||
if (hit.cross) {
|
||||
if (hit.part === 'cross') {
|
||||
this.options.onRemove?.(id);
|
||||
return;
|
||||
}
|
||||
const eyeRight = this.w - this.crossWidth;
|
||||
if (this.options.onToggleHidden && local.x >= eyeRight - EYE_SLOT && local.x < eyeRight) {
|
||||
this.options.onToggleHidden(id);
|
||||
if (hit.part === 'duplicate') {
|
||||
this.options.onDuplicate?.(id);
|
||||
return;
|
||||
}
|
||||
if (hit.part === 'eye') {
|
||||
this.options.onToggleHidden?.(id);
|
||||
return;
|
||||
}
|
||||
// Keyed by list and item, not by this widget: the first click's pick may rebuild the list.
|
||||
if (hit.part === 'name' && pressCount(e, `sortable:${this.options.title ?? ''}:${id}`) >= 2) {
|
||||
this.drag = null;
|
||||
this.startRename(hit.row);
|
||||
return;
|
||||
}
|
||||
if (this.options.multiSelect && (e.ctrlKey || e.metaKey || e.shiftKey)) {
|
||||
@@ -266,6 +413,43 @@ export class SortableList extends Widget {
|
||||
this.drag = { from: hit.row, pick, startY: y, y, active: false };
|
||||
}
|
||||
|
||||
private startRename(index: number): void {
|
||||
const item = this.shown[index];
|
||||
const onRename = this.options.onRename;
|
||||
if (!item || !onRename) return;
|
||||
const field = new RowNameField(item.label);
|
||||
field.onChange = (name) => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed !== item.label) onRename(item.id, trimmed);
|
||||
};
|
||||
field.onDone = () => {
|
||||
if (this.renaming?.field === field) this.renaming = null;
|
||||
// Not while the focus manager is still busy with it.
|
||||
queueMicrotask(() => {
|
||||
if (!field.destroyed) field.destroy();
|
||||
});
|
||||
if (!this.destroyed) this.invalidate();
|
||||
};
|
||||
this.renaming = { id: item.id, field };
|
||||
this.addChild(field);
|
||||
this.placeRenameField();
|
||||
ui().focus.focus(field);
|
||||
field.selectAll();
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
private placeRenameField(): void {
|
||||
const renaming = this.renaming;
|
||||
if (!renaming) return;
|
||||
const index = this.shown.findIndex((item) => item.id === renaming.id);
|
||||
const x = this.textX - PAD;
|
||||
renaming.field.position.set(
|
||||
x,
|
||||
this.headerHeight + index * SORTABLE_ROW_HEIGHT + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2,
|
||||
);
|
||||
renaming.field.resize(Math.max(40, this.nameRight - x - PAD), FIELD_HEIGHT);
|
||||
}
|
||||
|
||||
private onMove(e: FederatedPointerEvent): void {
|
||||
const drag = this.drag;
|
||||
if (!drag) return;
|
||||
@@ -297,18 +481,22 @@ export class SortableList extends Widget {
|
||||
.fill(Theme.colors.control);
|
||||
this.marker.clear();
|
||||
this.redrawHeader();
|
||||
const slots = this.slots;
|
||||
const nameRight = this.nameRight;
|
||||
const eyes = Boolean(this.options.onToggleHidden);
|
||||
const crosses = this.canRemove;
|
||||
const crossW = this.crossWidth;
|
||||
const duplicates = Boolean(this.options.onDuplicate);
|
||||
const value = this.options.value;
|
||||
const thumbs = this.options.thumb;
|
||||
const textX = thumbs ? PAD * 2 + THUMB : PAD * 2;
|
||||
const textX = this.textX;
|
||||
const hovered = this.over !== 'add' ? this.over : null;
|
||||
for (const [index, item] of this.shown.entries()) {
|
||||
const row = this.rows[index]!;
|
||||
const y = top + index * SORTABLE_ROW_HEIGHT;
|
||||
row.box.position.set(0, y);
|
||||
const selected = this.selected.includes(item.id);
|
||||
if (selected || hovered?.row === index)
|
||||
const underPointer = hovered?.row === index;
|
||||
if (selected || underPointer)
|
||||
this.background
|
||||
.rect(0, y, this.w, SORTABLE_ROW_HEIGHT)
|
||||
.fill(selected ? Theme.colors.selection : Theme.colors.controlHover);
|
||||
@@ -324,30 +512,51 @@ export class SortableList extends Widget {
|
||||
}
|
||||
row.art.alpha = item.hidden ? 0.35 : 1;
|
||||
|
||||
const eyeW = eyes ? EYE_SLOT : 0;
|
||||
row.hint.text = item.hint ?? '';
|
||||
const hintW = row.hint.text ? row.hint.width + PAD * 2 : 0;
|
||||
// The number, with its suffix right of it; without one, the owner's hint stands there.
|
||||
const number = value?.get(item.id) ?? null;
|
||||
row.hint.text = value ? (number !== null ? (value.suffix ?? '') : '') : (item.hint ?? '');
|
||||
row.hint.position.set(
|
||||
Math.round(this.w - crossW - eyeW - PAD - row.hint.width),
|
||||
Math.round(slots.value - PAD - row.hint.width),
|
||||
(SORTABLE_ROW_HEIGHT - row.hint.height) / 2,
|
||||
);
|
||||
const hintW = !value && row.hint.text ? row.hint.width + PAD * 2 : 0;
|
||||
if (row.field) {
|
||||
row.field.visible = number !== null;
|
||||
if (number !== null) row.field.setNumber(number);
|
||||
const suffix = value?.suffix ? row.hint.width + PAD : 0;
|
||||
row.field.position.set(
|
||||
slots.value - PAD - suffix - VALUE_WIDTH,
|
||||
y + (SORTABLE_ROW_HEIGHT - FIELD_HEIGHT) / 2,
|
||||
);
|
||||
row.field.resize(VALUE_WIDTH, FIELD_HEIGHT);
|
||||
}
|
||||
|
||||
const renamed = this.renaming?.id === item.id;
|
||||
row.label.visible = !renamed;
|
||||
row.label.style = textStyle(selected && !item.hidden ? 'body' : 'muted');
|
||||
row.label.text = truncate(
|
||||
item.label,
|
||||
row.label.style,
|
||||
Math.max(0, this.w - textX - hintW - eyeW - crossW - PAD),
|
||||
);
|
||||
row.label.text = truncate(item.label, row.label.style, Math.max(0, nameRight - textX - hintW - PAD));
|
||||
row.label.position.set(textX, (SORTABLE_ROW_HEIGHT - row.label.height) / 2);
|
||||
|
||||
const mid = SORTABLE_ROW_HEIGHT / 2;
|
||||
row.eye.clear();
|
||||
if (eyes) drawEye(row.eye, this.w - crossW - EYE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, !item.hidden);
|
||||
row.cross.clear();
|
||||
if (crosses) {
|
||||
const hot = hovered?.row === index && hovered.cross;
|
||||
drawCross(row.cross, this.w - REMOVE_SLOT / 2, SORTABLE_ROW_HEIGHT / 2, hot);
|
||||
if (eyes) drawEye(row.eye, slots.eye - EYE_SLOT / 2, mid, !item.hidden);
|
||||
// Only where the pointer or the selection is: a mark on every row would crowd a long list.
|
||||
const active = selected || underPointer;
|
||||
row.duplicate.clear();
|
||||
if (duplicates && active) {
|
||||
const hot = underPointer && hovered.part === 'duplicate';
|
||||
drawDuplicate(row.duplicate, slots.duplicate - DUPLICATE_SLOT / 2, mid, hot);
|
||||
}
|
||||
row.box.alpha = this.drag?.active && this.drag.from === index ? 0.4 : 1;
|
||||
row.cross.clear();
|
||||
if (crosses && active) {
|
||||
const hot = underPointer && hovered.part === 'cross';
|
||||
drawCross(row.cross, slots.cross - REMOVE_SLOT / 2, mid, hot);
|
||||
}
|
||||
const dragged = this.drag?.active && this.drag.from === index;
|
||||
row.box.alpha = dragged ? 0.4 : 1;
|
||||
if (row.field) row.field.alpha = dragged ? 0.4 : 1;
|
||||
}
|
||||
this.placeRenameField();
|
||||
if (this.drag?.active) {
|
||||
const y = top + dropGap(this.shown.length, this.drag.y, SORTABLE_ROW_HEIGHT) * SORTABLE_ROW_HEIGHT;
|
||||
this.marker.rect(0, Math.min(y, this.h - 2), this.w, 2).fill(Theme.colors.accent);
|
||||
@@ -379,7 +588,21 @@ export class SortableList extends Widget {
|
||||
|
||||
function sameHover(a: Hover, b: Hover): boolean {
|
||||
if (a === null || b === null || a === 'add' || b === 'add') return a === b;
|
||||
return a.row === b.row && a.cross === b.cross;
|
||||
return a.row === b.row && a.part === b.part;
|
||||
}
|
||||
|
||||
const suffixWidths = new Map<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. */
|
||||
@@ -393,6 +616,13 @@ function drawEye(g: Graphics, x: number, y: number, open: boolean): void {
|
||||
.stroke({ width: 1.2, color });
|
||||
}
|
||||
|
||||
/** Two overlapping squares, the usual sign for a copy: muted, and in the text colour under the pointer. */
|
||||
function drawDuplicate(g: Graphics, x: number, y: number, hot: boolean): void {
|
||||
const color = hot ? Theme.colors.text : Theme.colors.textMuted;
|
||||
g.rect(x - 5, y - 5, 7, 7).stroke({ width: 1.2, color });
|
||||
g.rect(x - 2, y - 2, 7, 7).stroke({ width: 1.2, color });
|
||||
}
|
||||
|
||||
/** The cross that removes a row: muted, and in the danger colour under the pointer. */
|
||||
function drawCross(g: Graphics, x: number, y: number, hot: boolean): void {
|
||||
g.moveTo(x - 4, y - 4)
|
||||
|
||||
Reference in new issue
Block a user