Open a sprite's texture or shader from the menus and inspector (#221)
A sprite's texture and shader were only reachable by hunting for them
in the asset panel. Every place that shows such a reference now leads
straight to it.
- Object menu (hierarchy and scene view): "Open texture" and "Open
shader" sit right below "Open asset" when the sprite has one. The
object's own values count, so an overridden shader opens the
override.
- Asset context menu: the same entries at the top.
- Inspector: every asset field with a value shows its label as a blue,
underlined link. One click selects the asset, a double click opens
it. An override stays gold and gets the underline too.
- The first click rebuilds the inspector, so the second one lands on a
different row. The inspector catches that second click before any
row sees it.
- New pure rule `references.ts`: the asset behind a reference field. A
frame leads to its sheet. Kinds without a tab (palette, font, sound)
get no menu entry.
- `followsPress` in `clicks.ts` keeps the double-click window and
tolerance in one place.
- `activateAsset` in `actions.ts`: opening means the same everywhere
as a double click in the asset panel, so a scene also becomes the
active one. The asset panel uses it too.
Tests:
- ./check.sh: 150 files, 2226 tests, CHECK EXIT 0 (before 148/2214).
- 12 new unit tests: 6 for the reference rule, 4 for the link in
createFieldRow, 2 for followsPress.
- 5 new e2e checks in 28l-ii: both menus, the link's look, click
selects, double click opens.
- e2e 1-16,26,28,35,39,40 on the final build: ALL PASSED, 732 checks.
- Counter-probes: no "Open ..." entries, no underline and a second
click never recognised each fail exactly at their checks.
- Check total 1341 (CI counted 1336 at fd6af84), computed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b62d572b4
commit
1aae97b335
16 files changed
+512
-30
No files matched your search
@@ -0,0 +1,37 @@
|
||||
import { type PropertyBag, type PropertyValue, textOf } from '../model/Properties';
|
||||
import { findPixelFrame } from '../model/pixelart';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import { findFrame } from '../model/texture';
|
||||
import { isKindWith } from './kinds';
|
||||
import type { PropertyDef } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* The asset a reference field points at, null when it names nothing or no longer resolves. A frame
|
||||
* names the texture or pixel art it was cut from – that is the asset one can open (#221).
|
||||
*/
|
||||
export function referencedAssetId(project: Project, def: PropertyDef, value: PropertyValue): string | null {
|
||||
if (!isKindWith('reference', def.kind)) return null;
|
||||
const id = textOf(value);
|
||||
if (id === '') return null;
|
||||
if (findAsset(project, id)) return id;
|
||||
if (def.kind !== 'texture') return null;
|
||||
return findFrame(project, id)?.asset.id ?? findPixelFrame(project, id)?.asset.id ?? null;
|
||||
}
|
||||
|
||||
export interface Reference {
|
||||
label: string;
|
||||
assetId: string;
|
||||
}
|
||||
|
||||
/** Every reference of a bag that resolves, in schema order – the "Open …" entries of a menu. */
|
||||
export function referencesOf(
|
||||
project: Project,
|
||||
schema: readonly PropertyDef[],
|
||||
values: PropertyBag,
|
||||
): Reference[] {
|
||||
return schema.flatMap((def) => {
|
||||
const value = values[def.key];
|
||||
const assetId = value === undefined ? null : referencedAssetId(project, def, value);
|
||||
return assetId ? [{ label: def.label, assetId }] : [];
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -3,7 +3,7 @@
|
||||
* menu, keyboard): they run the command and put the selection where the user expects it.
|
||||
*/
|
||||
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
|
||||
import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
||||
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { AddColliderCommand, AddRigidbodyCommand, rigidbodyRefusal } from '../core/commands/colliderCommands';
|
||||
import { oneStep } from '../core/commands/CompoundCommand';
|
||||
import { CreateContainerCommand } from '../core/commands/containerCommands';
|
||||
@@ -23,6 +23,15 @@ import { Dialog } from '../ui';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
||||
/**
|
||||
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
|
||||
* in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one.
|
||||
*/
|
||||
export function activateAsset(state: EditorState, assetId: string): void {
|
||||
if (findAsset(state.project, assetId)?.type === 'scene') state.execute(new SetActiveSceneCommand(assetId));
|
||||
state.openAsset(assetId);
|
||||
}
|
||||
|
||||
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||||
const command = new CreateContainerCommand(holderId, objectIds);
|
||||
if (!state.execute(command)) return false;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssetType } from '../../core/model/AssetTypes';
|
||||
import { Button, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui';
|
||||
import { Button, Label, Panel, Widget, activeDrag, currentMenu, ui } from '../../ui';
|
||||
import { Theme } from '../../ui/Theme';
|
||||
import { currentFontPicker } from '../font/FontPicker';
|
||||
import { currentSoundPicker } from '../sound/SoundPicker';
|
||||
@@ -140,6 +140,28 @@ export function uiHooks({ app, state, editor }: HookContext) {
|
||||
return (last ? hits.at(-1) : hits[0]) ?? null;
|
||||
},
|
||||
inspectorField: fieldOf,
|
||||
/**
|
||||
* The label of an inspector row as a link (#221): its centre, colour and whether it is underlined
|
||||
* and takes presses. Searched in the inspector only – the tab row says "Shader" too.
|
||||
*/
|
||||
inspectorLink: (text: string) => {
|
||||
let found: Label | null = null;
|
||||
const walk = (node: Container): void => {
|
||||
if (found || !node.visible) return;
|
||||
if (node instanceof Label && node.text === text) found = node;
|
||||
else for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(inspector);
|
||||
const label = found as Label | null;
|
||||
if (!label) return null;
|
||||
const shown = label.children.find((child): child is Text => child instanceof Text);
|
||||
return {
|
||||
...centreOf(label),
|
||||
color: shown ? new Color(shown.style.fill as ColorSource).toHex() : null,
|
||||
underlined: label.underlined,
|
||||
pressable: label.eventMode === 'static',
|
||||
};
|
||||
},
|
||||
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
|
||||
inspectorRect: () => rectOf(inspector),
|
||||
/** Where the focused widget sits on screen, or `null` – for checks on its focus border. */
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { Label, Panel, ScrollContainer, Theme, ui, VStack, type Widget } from '../../ui';
|
||||
import type { FederatedPointerEvent } from 'pixi.js';
|
||||
import {
|
||||
followsPress,
|
||||
Label,
|
||||
Panel,
|
||||
type Press,
|
||||
pressOf,
|
||||
ScrollContainer,
|
||||
Theme,
|
||||
ui,
|
||||
VStack,
|
||||
type Widget,
|
||||
} from '../../ui';
|
||||
import { activateAsset } from '../actions';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import { annotationSignature, buildAnnotationSection, refreshAnnotationSection } from './annotationSection';
|
||||
@@ -34,6 +47,7 @@ export class Inspector extends Panel {
|
||||
/** A Variables asset's live values, set by the editor – the only place that knows the running worlds. */
|
||||
liveVariables: (assetId: string) => PropertyBag | null = () => null;
|
||||
private readonly releaseFocusHandler: () => void;
|
||||
private followedLink: { assetId: string; press: Press } | null = null;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ title: 'Inspector', padding: 0, gap: 0 });
|
||||
@@ -47,9 +61,30 @@ export class Inspector extends Panel {
|
||||
this.releaseFocusHandler = ui().focus.onFocusChange(() => {
|
||||
if (this.rebuildWhenLeft && !ui().focus.isWithin(this)) this.rebuild();
|
||||
});
|
||||
// Capture: the press lands on whatever row the rebuilt panel has there, and must not reach it.
|
||||
this.addEventListener(
|
||||
'pointerdown',
|
||||
(e: FederatedPointerEvent) => {
|
||||
const link = this.followedLink;
|
||||
this.followedLink = null;
|
||||
if (e.button !== 0 || !link || !followsPress(link.press, e)) return;
|
||||
e.stopPropagation();
|
||||
activateAsset(this.state, link.assetId);
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
this.rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* A link in a reference row (#221): a click selects the asset, which rebuilds this panel for it,
|
||||
* so the second press of a double-click is recognised here rather than on the link.
|
||||
*/
|
||||
private followReference(assetId: string, e: FederatedPointerEvent): void {
|
||||
this.state.select({ kind: 'asset', assetId });
|
||||
this.followedLink = { assetId, press: pressOf(e) };
|
||||
}
|
||||
|
||||
/** Signing out destroys the panel while the page stays up (#177), so the handler goes with it. */
|
||||
override destroy(options?: Parameters<Widget['destroy']>[0]): void {
|
||||
this.releaseFocusHandler();
|
||||
@@ -67,6 +102,7 @@ export class Inspector extends Panel {
|
||||
// Read through the state on every call: load and play mode replace the project object.
|
||||
project: () => this.state.project,
|
||||
liveVariables: (assetId) => this.liveVariables(assetId),
|
||||
followReference: (assetId, e) => this.followReference(assetId, e),
|
||||
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
|
||||
fields: new Map(),
|
||||
nameField: null,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import { type FederatedPointerEvent, Graphics } from 'pixi.js';
|
||||
import { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
|
||||
import type { Project } from '../../core/model/Project';
|
||||
import {
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type PropertyDef,
|
||||
type StructFieldDef,
|
||||
} from '../../core/properties/PropertySchema';
|
||||
import { referencedAssetId } from '../../core/properties/references';
|
||||
import { NO_TEXTURE_LABEL, pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||||
import {
|
||||
Button,
|
||||
@@ -52,6 +53,11 @@ import { ROW_ACTION_ICON_SVG } from '../panels/markIcons';
|
||||
*/
|
||||
export interface FieldContext {
|
||||
project(): Project;
|
||||
/**
|
||||
* A press on the label of a reference row that names an asset (#221). Without it the label stays
|
||||
* plain text – only the inspector knows how a second press on a rebuilt panel opens the asset.
|
||||
*/
|
||||
followReference?(assetId: string, e: FederatedPointerEvent): void;
|
||||
}
|
||||
|
||||
export interface FieldHandlers {
|
||||
@@ -220,11 +226,35 @@ export function createFieldRow(
|
||||
|
||||
const refreshActions = attachRowActions(row, rowActions);
|
||||
|
||||
const follow = ctx.followReference?.bind(ctx);
|
||||
let modified = false;
|
||||
let target: string | null = null;
|
||||
const paintLabel = (): void => {
|
||||
// An override keeps its colour: that it deviates says more than that it can be followed.
|
||||
label.setStyle(modified ? 'override' : target ? 'link' : 'muted');
|
||||
label.setUnderline(target !== null);
|
||||
label.eventMode = target ? 'static' : 'none';
|
||||
};
|
||||
const retarget = (v: PropertyValue): void => {
|
||||
if (!follow) return;
|
||||
target = referencedAssetId(ctx.project(), def, v);
|
||||
paintLabel();
|
||||
};
|
||||
if (follow)
|
||||
label.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0 && target) follow(target, e);
|
||||
});
|
||||
retarget(value);
|
||||
|
||||
return {
|
||||
row,
|
||||
setValue: field.setValue,
|
||||
setModified(modified) {
|
||||
label.setStyle(modified ? 'override' : 'muted');
|
||||
setValue: (v) => {
|
||||
field.setValue(v);
|
||||
retarget(v);
|
||||
},
|
||||
setModified(isModified) {
|
||||
modified = isModified;
|
||||
paintLabel();
|
||||
refreshActions();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,9 +2,14 @@ import { UnpackContainerCommand } from '../core/commands/containerCommands';
|
||||
import { SetParentCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { isLiveInstance, isTemplateContent } from '../core/model/instance';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import { findAsset, type ObjectHolder } from '../core/model/Project';
|
||||
import { type PropertyDef, schemaForObject } from '../core/properties/PropertySchema';
|
||||
import { referencesOf } from '../core/properties/references';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import { Menu, type MenuItem } from '../ui';
|
||||
import {
|
||||
activateAsset,
|
||||
addCollider,
|
||||
addRigidbody,
|
||||
canAddCollider,
|
||||
@@ -51,6 +56,13 @@ export function openObjectMenu(
|
||||
disabled: !asset || !hasAssetEditor(asset.type),
|
||||
onSelect: () => object && state.openAsset(object.assetId),
|
||||
},
|
||||
...(object && asset
|
||||
? openReferenceItems(
|
||||
state,
|
||||
schemaForObject(state.project, asset, object),
|
||||
resolveSceneObjectProps(state.project, asset, object),
|
||||
)
|
||||
: []),
|
||||
{
|
||||
label: 'Add collider',
|
||||
disabled: !object || many || !canAddCollider(state.project, source, object),
|
||||
@@ -89,3 +101,24 @@ export function openObjectMenu(
|
||||
menu.open(x, y);
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Open texture", "Open shader", … – one entry per asset the values point at (#221), skipping kinds
|
||||
* that have no tab to open. The asset panel offers the same for an asset's own values.
|
||||
*/
|
||||
export function openReferenceItems(
|
||||
state: EditorState,
|
||||
schema: readonly PropertyDef[],
|
||||
values: PropertyBag,
|
||||
): MenuItem[] {
|
||||
return referencesOf(state.project, schema, values).flatMap(({ label, assetId }) => {
|
||||
const target = findAsset(state.project, assetId);
|
||||
if (!target || !hasAssetEditor(target.type)) return [];
|
||||
return [
|
||||
{
|
||||
label: `Open ${label.charAt(0).toLowerCase()}${label.slice(1)}`,
|
||||
onSelect: () => activateAsset(state, assetId),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||||
import { assetDeviates } from '../../core/properties/deviation';
|
||||
import { schemaForAsset } from '../../core/properties/PropertySchema';
|
||||
import { resolveAssetProps } from '../../core/properties/resolve';
|
||||
import type { FontStore } from '../../core/store/FontStore';
|
||||
import type { SoundStore } from '../../core/store/SoundStore';
|
||||
import type { TextureStore } from '../../core/store/TextureStore';
|
||||
@@ -27,7 +29,6 @@ import {
|
||||
ListView,
|
||||
Menu,
|
||||
type MenuEntry,
|
||||
type MenuItem,
|
||||
Panel,
|
||||
ScrollContainer,
|
||||
startDrag,
|
||||
@@ -37,7 +38,8 @@ import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups';
|
||||
import { ASSET_ICON_SVG } from './assetIcons';
|
||||
import { assetMenuGroups } from './assetMenu';
|
||||
import { TOOLBAR_ICON_SVG } from './toolbarIcons';
|
||||
import { confirmDeleteAsset } from '../actions';
|
||||
import { activateAsset, confirmDeleteAsset } from '../actions';
|
||||
import { openReferenceItems } from '../objectMenu';
|
||||
import type { EditorState } from '../state';
|
||||
import { openFontPicker } from '../font/FontPicker';
|
||||
import { openSoundPicker } from '../sound/SoundPicker';
|
||||
@@ -275,9 +277,7 @@ export class AssetPanel extends Panel {
|
||||
|
||||
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
|
||||
private activate(id: string): void {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id));
|
||||
this.state.openAsset(id);
|
||||
activateAsset(this.state, id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,7 +317,12 @@ export class AssetPanel extends Panel {
|
||||
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
if (!asset) return undefined;
|
||||
const items: MenuItem[] = [];
|
||||
const project = this.state.project;
|
||||
const items = openReferenceItems(
|
||||
this.state,
|
||||
schemaForAsset(project, asset),
|
||||
resolveAssetProps(project, asset),
|
||||
);
|
||||
if (asset.type === 'scene') {
|
||||
items.push({
|
||||
label: 'Set as active scene',
|
||||
|
||||
@@ -87,6 +87,7 @@ export type TextStyleToken =
|
||||
| 'bodyBold'
|
||||
| 'muted'
|
||||
| 'override'
|
||||
| 'link'
|
||||
| 'small'
|
||||
| 'warning'
|
||||
| 'live'
|
||||
@@ -117,6 +118,9 @@ export function textStyle(token: TextStyleToken = 'body'): TextStyle {
|
||||
case 'override':
|
||||
style = new TextStyle({ ...base, fill: Theme.colors.override });
|
||||
break;
|
||||
case 'link':
|
||||
style = new TextStyle({ ...base, fill: Theme.colors.accent });
|
||||
break;
|
||||
case 'small':
|
||||
style = new TextStyle({ ...base, fontSize: Theme.smallFontSize, fill: Theme.colors.textMuted });
|
||||
break;
|
||||
|
||||
+21
-13
@@ -5,12 +5,28 @@ const DOUBLE_MS = 400;
|
||||
/** How far the pointer may travel between them. */
|
||||
const SLOP = 6;
|
||||
|
||||
interface LastPress {
|
||||
key: unknown;
|
||||
button: number;
|
||||
/** When and where a press happened. */
|
||||
export interface Press {
|
||||
time: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export const pressOf = (e: FederatedPointerEvent): Press => ({
|
||||
time: e.timeStamp,
|
||||
x: e.global.x,
|
||||
y: e.global.y,
|
||||
});
|
||||
|
||||
/** Whether `e` comes soon enough and close enough after `first` to make a double-click with it. */
|
||||
export function followsPress(first: Press, e: FederatedPointerEvent): boolean {
|
||||
const gap = e.timeStamp - first.time;
|
||||
return gap >= 0 && gap <= DOUBLE_MS && Math.hypot(e.global.x - first.x, e.global.y - first.y) <= SLOP;
|
||||
}
|
||||
|
||||
interface LastPress extends Press {
|
||||
key: unknown;
|
||||
button: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
@@ -21,20 +37,12 @@ let last: LastPress = { key: null, button: -1, time: 0, x: 0, y: 0, count: 0 };
|
||||
* must survive a rebuild of its widget: the first click's selection can recreate the list rows.
|
||||
*/
|
||||
export function pressCount(e: FederatedPointerEvent, key: unknown): number {
|
||||
const gap = e.timeStamp - last.time;
|
||||
const repeat =
|
||||
key === last.key &&
|
||||
e.button === last.button &&
|
||||
gap >= 0 &&
|
||||
gap <= DOUBLE_MS &&
|
||||
Math.hypot(e.global.x - last.x, e.global.y - last.y) <= SLOP;
|
||||
const repeat = key === last.key && e.button === last.button && followsPress(last, e);
|
||||
const count = repeat ? last.count + 1 : 1;
|
||||
last = {
|
||||
...pressOf(e),
|
||||
key,
|
||||
button: e.button,
|
||||
time: e.timeStamp,
|
||||
x: e.global.x,
|
||||
y: e.global.y,
|
||||
// A finished double-click ends the run: folding a row open and shut again is two
|
||||
// double-clicks, and the second must count as one rather than as clicks three and four.
|
||||
count: count === 2 ? 0 : count,
|
||||
|
||||
+1
-1
@@ -70,5 +70,5 @@ export {
|
||||
rgbToHsv,
|
||||
toHex,
|
||||
} from './color';
|
||||
export { pressCount } from './clicks';
|
||||
export { followsPress, type Press, pressCount, pressOf } from './clicks';
|
||||
export { drawSvg, tintSvg } from './svg';
|
||||
+23
-1
@@ -1,4 +1,4 @@
|
||||
import { CanvasTextMetrics, Text } from 'pixi.js';
|
||||
import { CanvasTextMetrics, Graphics, Text } from 'pixi.js';
|
||||
import { centreY } from '../math';
|
||||
import { Theme, textStyle, type TextStyleToken } from '../Theme';
|
||||
import { type Size, Widget } from '../Widget';
|
||||
@@ -18,6 +18,7 @@ export class Label extends Widget {
|
||||
private readonly align: NonNullable<LabelOptions['align']>;
|
||||
private readonly padding: number;
|
||||
private readonly fixedWidth: number;
|
||||
private underline: Graphics | null = null;
|
||||
|
||||
constructor(text = '', options: LabelOptions = {}) {
|
||||
super();
|
||||
@@ -49,6 +50,22 @@ export class Label extends Widget {
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** A line under the shown text in its own colour – how a label reads as a link. */
|
||||
setUnderline(on: boolean): void {
|
||||
if (on === Boolean(this.underline)) return;
|
||||
if (on) this.underline = this.addChild(new Graphics());
|
||||
else {
|
||||
this.underline?.destroy();
|
||||
this.underline = null;
|
||||
}
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** Whether the label is underlined (test hook). */
|
||||
get underlined(): boolean {
|
||||
return this.underline !== null;
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
if (this.fixedWidth) return { w: this.fixedWidth, h: this.minHeight };
|
||||
const metrics = CanvasTextMetrics.measureText(this.fullText, this.textNode.style);
|
||||
@@ -66,6 +83,11 @@ export class Label extends Widget {
|
||||
if (this.align === 'center') x = (this.w - tw) / 2;
|
||||
else if (this.align === 'right') x = this.w - this.padding - tw;
|
||||
this.textNode.position.set(Math.round(x), centreY(this.h, this.textNode.height));
|
||||
if (this.underline) {
|
||||
const y = Math.round(this.textNode.y + this.textNode.height) - 1;
|
||||
const color = this.textNode.style.fill as number;
|
||||
this.underline.clear().rect(this.textNode.x, y, tw, 1).fill(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user