Add pixel-size fit and drag-to-reorder in the hierarchy
The aspect button now carries two offers, so it becomes a menu: fit one side to the texture ratio, or set both sides to the exact pixel size as a single undo step. Dropping a row on the outer quarter of another row inserts it into that gap instead of parenting into it, reordering among the siblings there. Order is the stored order, so ReorderObjectCommand is a splice; equal z falls back to tree order, which makes this the way to draw one object in front of another. Rejected on container content like the other structural edits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f40b8a0523
commit
4568c6581b
17 files changed
+416
-85
No files matched your search
@@ -208,6 +208,50 @@ export class ClearOverrideCommand extends OverrideCommand {
|
||||
* Re-parents an object ('' = top level). It keeps its world position: the local one is recomputed
|
||||
* for the new parent.
|
||||
*/
|
||||
/**
|
||||
* Moves an object among its siblings. Sibling order is the order the stored list happens to have
|
||||
* (`treeOrder`), so the move is a splice – and because objects sharing a `z` fall back to that
|
||||
* order, it is also how two overlapping objects are put in front of each other.
|
||||
*/
|
||||
export class ReorderObjectCommand implements Command {
|
||||
readonly label = 'Reorder object';
|
||||
private previousIndex = -1;
|
||||
|
||||
/** `beforeId` empty means "behind its last sibling". */
|
||||
constructor(
|
||||
private readonly holderId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly beforeId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
requireStructural(project, holder, this.objectId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
if (this.beforeId === this.objectId)
|
||||
throw new CommandError('An object cannot be moved in front of itself');
|
||||
let at = holder.objects.length;
|
||||
if (this.beforeId) {
|
||||
requireStructural(project, holder, this.beforeId);
|
||||
const before = requireObject(project, this.holderId, this.beforeId);
|
||||
if (before.parentId !== object.parentId)
|
||||
throw new CommandError('An object can only be reordered among its own siblings');
|
||||
at = holder.objects.indexOf(before);
|
||||
}
|
||||
this.previousIndex = holder.objects.indexOf(object);
|
||||
holder.objects.splice(this.previousIndex, 1);
|
||||
// The index was read before the removal, so anything behind it has moved up by one.
|
||||
holder.objects.splice(at > this.previousIndex ? at - 1 : at, 0, object);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const object = requireObject(project, this.holderId, this.objectId);
|
||||
holder.objects.splice(holder.objects.indexOf(object), 1);
|
||||
holder.objects.splice(this.previousIndex, 0, object);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetParentCommand implements Command {
|
||||
readonly label = 'Set parent';
|
||||
private previous: { parentId: string; position: Position } = {
|
||||
|
||||
@@ -19,6 +19,11 @@ function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
|
||||
return tree.objects.filter((o) => o.parentId === parentId);
|
||||
}
|
||||
|
||||
/** Children of `parentId` (`''` = top level) in sibling order – what a reorder rearranges. */
|
||||
export function childIdsOf(tree: ObjectTree, parentId: string): string[] {
|
||||
return childrenOf(tree, parentId).map((o) => o.id);
|
||||
}
|
||||
|
||||
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
|
||||
return object.parentId ? findObject(tree, object.parentId) : undefined;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ export function fitAxisToAspect(size: PixelSize, props: PropertyBag, axis: SizeA
|
||||
return Number.isFinite(fitted) && fitted > 0 ? fitted : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sprite is drawn at the picture's own pixel size – one image pixel per world unit.
|
||||
* Exact, unlike `matchesAspect`: these two numbers are written as they are, with no division in
|
||||
* between that could cost a digit.
|
||||
*/
|
||||
export function matchesPixelSize(size: PixelSize, props: PropertyBag): boolean {
|
||||
return Number(props.width) === size.w && Number(props.height) === size.h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the sprite already has the picture's proportions. Compared as a cross product with a
|
||||
* relative tolerance, because fitting one side and then asking about the other must not answer
|
||||
|
||||
@@ -1,29 +1,58 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import {
|
||||
fitAxisToAspect,
|
||||
matchesAspect,
|
||||
matchesPixelSize,
|
||||
type PixelSize,
|
||||
type SizeAxis,
|
||||
texturePixelSize,
|
||||
} from '../../core/properties/aspect';
|
||||
import { coerceValue, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||||
import type { FieldActionGroup } from './fieldFactory';
|
||||
import type { FieldAction, FieldActionGroup } from './fieldFactory';
|
||||
import type { InspectorHost } from './host';
|
||||
|
||||
/** ↔ sets the width, ↕ the height – the side the button changes, not the one it reads. */
|
||||
const AXIS_GLYPH: Record<SizeAxis, string> = { width: '↔', height: '↕' };
|
||||
|
||||
/** What a size action writes: one side for a fit, both for the picture's pixel size. */
|
||||
export type SizeValues = Partial<Record<SizeAxis, number>>;
|
||||
|
||||
/** One undo step whether the action wrote one side or both. */
|
||||
export function sizeCommand(values: SizeValues, make: (axis: SizeAxis, value: number) => Command): Command {
|
||||
const commands = (Object.entries(values) as [SizeAxis, number][]).map(([axis, value]) => make(axis, value));
|
||||
return commands.length === 1 ? commands[0]! : new CompoundCommand('Set size', commands);
|
||||
}
|
||||
|
||||
/** The pixel size as the schema would store it – null where a min/max would change a side. */
|
||||
function storablePixelSize(asset: Asset, size: PixelSize): SizeValues | null {
|
||||
const schema = schemaForAsset(asset);
|
||||
const values: SizeValues = {};
|
||||
for (const axis of ['width', 'height'] as const) {
|
||||
const def = schema.find((d) => d.key === axis);
|
||||
if (!def) return null;
|
||||
const wanted = axis === 'width' ? size.w : size.h;
|
||||
const next = Number(coerceValue(def, wanted));
|
||||
if (next !== wanted) return null;
|
||||
values[axis] = next;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Fit this side to the picture's proportions" for the Width/Height rows of an asset that also
|
||||
* wears a texture. Empty for every other row, and – through an action list that comes back
|
||||
* empty – while no texture or frame is assigned, so the button is not drawn at all.
|
||||
* The size actions of the Width/Height rows of an asset that also wears a texture: fit this side
|
||||
* to the picture's proportions, or set both sides to its pixel size. Empty for every other row,
|
||||
* and – through an action list that comes back empty – while no texture or frame is assigned, so
|
||||
* the button is not drawn at all.
|
||||
*/
|
||||
export function aspectActions(
|
||||
host: InspectorHost,
|
||||
asset: Asset,
|
||||
def: PropertyDef,
|
||||
props: () => PropertyBag | null,
|
||||
commit: (value: number) => void,
|
||||
commit: (values: SizeValues) => void,
|
||||
): FieldActionGroup[] {
|
||||
if (def.kind !== 'number' || (def.key !== 'width' && def.key !== 'height')) return [];
|
||||
// A size without a picture (collider, camera) has no proportions to fit to.
|
||||
@@ -35,14 +64,23 @@ export function aspectActions(
|
||||
available: () => {
|
||||
const bag = props();
|
||||
const size = bag && texturePixelSize(host.project(), bag);
|
||||
if (!bag || !size || matchesAspect(size, bag)) return [];
|
||||
const fitted = fitAxisToAspect(size, bag, axis);
|
||||
if (fitted === null) return [];
|
||||
if (!bag || !size) return [];
|
||||
const actions: FieldAction[] = [];
|
||||
const fitted = matchesAspect(size, bag) ? null : fitAxisToAspect(size, bag, axis);
|
||||
// Offered only when the schema would store it unchanged: a value its min/max clamps
|
||||
// cannot make the sprite proportional, so the button would never go away again.
|
||||
const next = Number(coerceValue(def, fitted));
|
||||
if (next !== fitted) return [];
|
||||
return [{ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit(next) }];
|
||||
const next = fitted === null ? null : Number(coerceValue(def, fitted));
|
||||
if (next !== null && next === fitted) {
|
||||
actions.push({ label: `Fit to ${size.w} × ${size.h}`, onSelect: () => commit({ [axis]: next }) });
|
||||
}
|
||||
const pixels = matchesPixelSize(size, bag) ? null : storablePixelSize(asset, size);
|
||||
if (pixels) {
|
||||
actions.push({
|
||||
label: `Set to ${size.w} × ${size.h} px`,
|
||||
onSelect: () => commit(pixels),
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||
import { aspectActions } from './aspectAction';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -101,7 +101,10 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
return target ? resolveAssetProps(target) : null;
|
||||
},
|
||||
(value) => host.state.execute(new SetAssetPropertyCommand(asset.id, def.key, value)),
|
||||
(values) =>
|
||||
host.state.execute(
|
||||
sizeCommand(values, (axis, value) => new SetAssetPropertyCommand(asset.id, axis, value)),
|
||||
),
|
||||
),
|
||||
reset: () => {
|
||||
const target = findAsset(host.state.project, asset.id);
|
||||
|
||||
@@ -56,7 +56,7 @@ export interface FieldRow {
|
||||
const FIELD_LABEL_WIDTH = 64;
|
||||
|
||||
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
|
||||
interface FieldAction {
|
||||
export interface FieldAction {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
} from '../../core/properties/resolve';
|
||||
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { aspectActions } from './aspectAction';
|
||||
import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
@@ -379,7 +379,10 @@ function propertyRow(
|
||||
owner,
|
||||
def,
|
||||
() => currentProps(host.state, holderId, objectId),
|
||||
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
|
||||
(values) =>
|
||||
host.state.execute(
|
||||
sizeCommand(values, (axis, value) => new SetOverrideCommand(holderId, objectId, axis, value)),
|
||||
),
|
||||
),
|
||||
reset: () => {
|
||||
const { template, asset } = bases();
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import { CompoundCommand } from '../../core/commands/CompoundCommand';
|
||||
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
|
||||
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { ReorderObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { hasColorSwatch } from '../../core/model/AssetTypes';
|
||||
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
|
||||
import { ancestorIdsOf, childIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
|
||||
import { isLiveInstance, isTemplateContent, resolvedHolder } from '../../core/model/instance';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
|
||||
import { type DropSpot, ListView, Menu, Panel, ScrollContainer } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
|
||||
/**
|
||||
* Objects of the scene or container being edited, as a tree (children indented below their parent).
|
||||
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
|
||||
* another to parent it there; drop it on the empty space below the rows to move it to the
|
||||
* top level. The object keeps its world position either way.
|
||||
* another to parent it there, into the gap above or below a row to order it among that row's
|
||||
* siblings, or onto the empty space below the rows to move it to the top level. The object keeps
|
||||
* its world position every time.
|
||||
*
|
||||
* Rows with children fold: the chevron (or ←/→, or a double-click on the row) toggles them.
|
||||
* Everything starts collapsed – which ids are open is view state kept here, never part of the
|
||||
@@ -42,14 +45,7 @@ export class HierarchyPanel extends Panel {
|
||||
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
|
||||
else state.select(id ? objectSelection(holder.id, [id]) : null);
|
||||
};
|
||||
this.list.onDrop = (draggedId, targetId) => {
|
||||
const holder = this.holder();
|
||||
const object = holder && findObject(holder, draggedId);
|
||||
if (!holder || !object || object.parentId === (targetId ?? '')) return;
|
||||
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
|
||||
if (targetId) this.expanded.add(targetId);
|
||||
state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
|
||||
};
|
||||
this.list.onDrop = (draggedId, spot) => this.drop(draggedId, spot);
|
||||
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
|
||||
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
|
||||
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
|
||||
@@ -72,6 +68,45 @@ export class HierarchyPanel extends Panel {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* A drop either puts the dragged row inside another one (parenting, which keeps the world
|
||||
* position) or into a gap, which orders it among the siblings there – and does both when the
|
||||
* gap belongs to a parent the object did not have. A move that would leave the order as it is
|
||||
* does nothing, so a drag that lands where it started costs no undo step.
|
||||
*/
|
||||
private drop(draggedId: string, spot: DropSpot): void {
|
||||
const holder = this.holder();
|
||||
const object = holder && findObject(holder, draggedId);
|
||||
if (!holder || !object) return;
|
||||
if (spot.at === 'onto') {
|
||||
if (object.parentId === spot.targetId) return;
|
||||
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
|
||||
this.expanded.add(spot.targetId);
|
||||
this.state.execute(new SetParentCommand(holder.id, draggedId, spot.targetId));
|
||||
return;
|
||||
}
|
||||
let parentId = '';
|
||||
let beforeId = '';
|
||||
if (spot.at !== 'end') {
|
||||
const target = findObject(holder, spot.targetId);
|
||||
if (!target) return;
|
||||
parentId = target.parentId;
|
||||
const order = childIdsOf(holder, parentId);
|
||||
beforeId = spot.at === 'before' ? target.id : (order[order.indexOf(target.id) + 1] ?? '');
|
||||
}
|
||||
if (beforeId === draggedId) return;
|
||||
const siblings = childIdsOf(holder, parentId);
|
||||
const rest = siblings.filter((id) => id !== draggedId);
|
||||
const at = beforeId ? rest.indexOf(beforeId) : rest.length;
|
||||
const wanted = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
|
||||
if (object.parentId === parentId && wanted.join() === siblings.join()) return;
|
||||
const commands: Command[] = [];
|
||||
if (object.parentId !== parentId) commands.push(new SetParentCommand(holder.id, draggedId, parentId));
|
||||
commands.push(new ReorderObjectCommand(holder.id, draggedId, beforeId));
|
||||
if (parentId) this.expanded.add(parentId);
|
||||
this.state.execute(commands.length === 1 ? commands[0]! : new CompoundCommand('Move object', commands));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
|
||||
* active scene again.
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export { Checkbox } from './widgets/Checkbox';
|
||||
export { Panel } from './widgets/Panel';
|
||||
export { Splitter } from './widgets/Splitter';
|
||||
export { ScrollContainer } from './widgets/ScrollContainer';
|
||||
export { ListView, type ListItem, type PickModifiers } from './widgets/ListView';
|
||||
export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView';
|
||||
export { Popup, type PopupOptions } from './widgets/Popup';
|
||||
export { Menu, type MenuItem, type MenuOptions } from './widgets/Menu';
|
||||
export { Dialog, type DialogButton } from './widgets/Dialog';
|
||||
|
||||
+44
-15
@@ -28,13 +28,19 @@ export interface PickModifiers {
|
||||
shift: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land. `onto` is the row itself (the tree meaning: inside it), `before` and
|
||||
* `after` are the gaps around it, `end` is the empty space below the last row.
|
||||
*/
|
||||
export type DropSpot = { at: 'onto' | 'before' | 'after'; targetId: string } | { at: 'end' };
|
||||
|
||||
interface DragState {
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
/** Set once the pointer travelled past `DRAG_THRESHOLD`. */
|
||||
active: boolean;
|
||||
targetId: string | null;
|
||||
spot: DropSpot | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,8 +59,8 @@ export class ListView extends Widget {
|
||||
onActivate?: (id: string) => void;
|
||||
/** Returning the opened menu keeps its row marked for as long as it is up. */
|
||||
onContextMenu?: (id: string, globalX: number, globalY: number) => Popup | void;
|
||||
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
|
||||
onDrop?: (draggedId: string, targetId: string | null) => void;
|
||||
/** Called when a dragged row is released somewhere that could take it. */
|
||||
onDrop?: (draggedId: string, spot: DropSpot) => void;
|
||||
/**
|
||||
* Called when a row's chevron is clicked or ←/→ is pressed on it. Setting this makes the list
|
||||
* a tree: every row reserves the chevron slot, so labels stay aligned across a level.
|
||||
@@ -196,7 +202,7 @@ export class ListView extends Widget {
|
||||
if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id);
|
||||
if (e.detail === 2) this.onActivate?.(item.id);
|
||||
else if (this.onDrop) {
|
||||
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null };
|
||||
this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, spot: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -210,34 +216,57 @@ export class ListView extends Widget {
|
||||
this.cursor = 'grabbing';
|
||||
}
|
||||
const local = this.toLocal(e.global);
|
||||
const index = Math.floor(local.y / this.rowHeight);
|
||||
const target = local.x >= 0 && local.x <= this.w && index >= 0 ? this.items[index] : undefined;
|
||||
drag.targetId = target && target.id !== drag.id ? target.id : null;
|
||||
drag.spot = this.spotAt(local.x, local.y, drag.id);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop at this point would land. The outer quarter of a row is its gap, so the same
|
||||
* drag can put a row inside another (middle) or next to it (edge); a point on the dragged row
|
||||
* itself, or outside the list, takes nothing.
|
||||
*/
|
||||
private spotAt(x: number, y: number, draggedId: string): DropSpot | null {
|
||||
if (x < 0 || x > this.w || y < 0) return null;
|
||||
const index = Math.floor(y / this.rowHeight);
|
||||
const item = this.items[index];
|
||||
if (!item) return { at: 'end' };
|
||||
if (item.id === draggedId) return null;
|
||||
const offset = y - index * this.rowHeight;
|
||||
const edge = this.rowHeight / 4;
|
||||
if (offset < edge) return { at: 'before', targetId: item.id };
|
||||
if (offset > this.rowHeight - edge) return { at: 'after', targetId: item.id };
|
||||
return { at: 'onto', targetId: item.id };
|
||||
}
|
||||
|
||||
private onDragEnd(): void {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
this.cursor = 'default';
|
||||
if (!drag?.active) return;
|
||||
this.invalidate();
|
||||
this.onDrop?.(drag.id, drag.targetId);
|
||||
if (drag.spot) this.onDrop?.(drag.id, drag.spot);
|
||||
}
|
||||
|
||||
/** Highlights the row a drag would drop onto, or underlines the list for "drop at top level". */
|
||||
/** Outlines the row a drag would drop into, or draws a line in the gap it would drop between. */
|
||||
private drawDropMarker(): void {
|
||||
this.dropMarker.clear();
|
||||
const drag = this.drag;
|
||||
if (!drag?.active) return;
|
||||
const index = this.items.findIndex((i) => i.id === drag.targetId);
|
||||
if (index >= 0) {
|
||||
const spot = this.drag?.active ? this.drag.spot : null;
|
||||
if (!spot) return;
|
||||
const line = (y: number): void => {
|
||||
this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
|
||||
};
|
||||
if (spot.at === 'end') {
|
||||
line(Math.min(this.h, this.items.length * this.rowHeight) - 1);
|
||||
return;
|
||||
}
|
||||
const index = this.items.findIndex((i) => i.id === spot.targetId);
|
||||
if (index < 0) return;
|
||||
if (spot.at === 'onto') {
|
||||
this.dropMarker
|
||||
.rect(1, index * this.rowHeight + 1, this.w - 2, this.rowHeight - 2)
|
||||
.stroke({ width: 2, color: Theme.colors.accent });
|
||||
} else {
|
||||
const y = Math.min(this.h, this.items.length * this.rowHeight) - 1;
|
||||
this.dropMarker.rect(1, Math.max(0, y - 1), this.w - 2, 2).fill(Theme.colors.accent);
|
||||
line((index + (spot.at === 'after' ? 1 : 0)) * this.rowHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user