diff --git a/.changeset/ui-button-pressed.md b/.changeset/ui-button-pressed.md new file mode 100644 index 000000000..7df67e7cd --- /dev/null +++ b/.changeset/ui-button-pressed.md @@ -0,0 +1,6 @@ +--- +"@jolly-pixel/ui": minor +--- + +Add `pressed` to `jolly-button`, `count` to `jolly-tool-button` and `floating` to `jolly-rail`. +Export `FolderReorder`, the folder grip controller panes use. `jolly-folder` no longer toggles its own grab: its handler sets the new `grabbed` property. diff --git a/packages/editors/scene/ARCHITECTURE.md b/packages/editors/scene/ARCHITECTURE.md index fefdefd76..2476c3045 100644 --- a/packages/editors/scene/ARCHITECTURE.md +++ b/packages/editors/scene/ARCHITECTURE.md @@ -18,6 +18,7 @@ The `SceneDocument` of the target lease is the only state that is saved. Everyth - `SelectionStore` follows the document: an actor that a command or a reset removes leaves the selection. - Reparenting rewrites the moved actor's transform from the file data, so it keeps its world pose whatever the binding has drawn. - The inspector forms are row descriptors in `components/componentForms.ts`, `Script` included. A row edit becomes a `component-changed` patch of one top-level field, unset when set back to its default. A nested field rewrites its whole object, as the room requires. +- Dragging a component folder by its grip, or moving it with the keyboard, commits one `component-moved` command on drop. The order lives in the document, never in pane storage. A gesture is cancelled when the component list changes under it. ## Collaboration diff --git a/packages/editors/scene/index.html b/packages/editors/scene/index.html index 2d506183d..8b0571872 100644 --- a/packages/editors/scene/index.html +++ b/packages/editors/scene/index.html @@ -27,11 +27,11 @@
-
- - -
+ +
+ +
* { +#scene-viewport .viewport-toolbars > * { pointer-events: auto; } diff --git a/packages/editors/scene/src/app/sceneIcons.ts b/packages/editors/scene/src/app/sceneIcons.ts index a0895a938..bc10f59a6 100644 --- a/packages/editors/scene/src/app/sceneIcons.ts +++ b/packages/editors/scene/src/app/sceneIcons.ts @@ -248,24 +248,51 @@ registerIcon("gizmo-scale", svg` /> `, { tone: "coral" }); -registerIcon("move-up", svg` +registerIcon("gizmo-space-local", svg` + -`, { tone: "sky" }); +`, { tone: "amber" }); -registerIcon("move-down", svg` +registerIcon("gizmo-space-world", svg` + -`, { tone: "sky" }); +`, { tone: "teal" }); + +registerIcon("view-frame", svg` + + +`, { tone: "violet" }); diff --git a/packages/editors/scene/src/boot/EditorShell.ts b/packages/editors/scene/src/boot/EditorShell.ts index a64e4c9ff..0eb61f08c 100644 --- a/packages/editors/scene/src/boot/EditorShell.ts +++ b/packages/editors/scene/src/boot/EditorShell.ts @@ -20,7 +20,7 @@ const kAttachedTags = [ "jolly-scene-hierarchy", "jolly-scene-inspector", "jolly-scene-environment", - "jolly-scene-view-toolbar" + "jolly-scene-toolbar" ] as const satisfies readonly (keyof HTMLElementTagNameMap)[]; export interface EditorShellOptions { diff --git a/packages/editors/scene/src/boot/SceneEditor.ts b/packages/editors/scene/src/boot/SceneEditor.ts index 03a136c84..72d447326 100644 --- a/packages/editors/scene/src/boot/SceneEditor.ts +++ b/packages/editors/scene/src/boot/SceneEditor.ts @@ -110,7 +110,8 @@ export class SceneEditor { context.preferences, SCENE_KEYBINDINGS, (message) => console.warn(message) - ) + ), + keyboardLayout: context.keyboardLayout }); const { sceneManager } = editorRuntime.runtime.world; teardown.defer(() => sceneManager.unloadScene(scene)); diff --git a/packages/editors/scene/src/components/scriptProperties.ts b/packages/editors/scene/src/components/scriptProperties.ts index e094bc4fc..33a76844b 100644 --- a/packages/editors/scene/src/components/scriptProperties.ts +++ b/packages/editors/scene/src/components/scriptProperties.ts @@ -95,16 +95,20 @@ export function parseList( } } -export function freePropertyKey( +export function addProperty( properties: Readonly, - base = "property" -): string { - let key = base; - for (let suffix = 2; Object.hasOwn(properties, key); suffix++) { - key = `${base}${suffix}`; + key: string, + value: JSONValue +): JSONObject { + const trimmed = key.trim(); + if (trimmed === "" || Object.hasOwn(properties, trimmed)) { + return { ...properties }; } - return key; + return { + ...properties, + [trimmed]: value + }; } export function renameProperty( @@ -112,13 +116,17 @@ export function renameProperty( from: string, to: string ): JSONObject { - if (from === to || Object.hasOwn(properties, to)) { + const trimmed = to.trim(); + if (trimmed === "") { + return removeProperty(properties, from); + } + if (from === trimmed || Object.hasOwn(properties, trimmed)) { return { ...properties }; } return Object.fromEntries( Object.entries(properties).map( - ([key, value]) => [key === from ? to : key, value] + ([key, value]) => [key === from ? trimmed : key, value] ) ); } diff --git a/packages/editors/scene/src/editing/SceneEditing.ts b/packages/editors/scene/src/editing/SceneEditing.ts index 4359803e2..bfe43ac91 100644 --- a/packages/editors/scene/src/editing/SceneEditing.ts +++ b/packages/editors/scene/src/editing/SceneEditing.ts @@ -7,11 +7,13 @@ import type { SceneDocument, SceneEnvironmentPatch } from "@jolly-pixel/asset.scene/client"; +import type { ShortcutLabels } from "@jolly-pixel/controls"; // Import Internal Dependencies import type { SceneActions } from "./SceneActions.ts"; import type { SceneHistory } from "../features/history/sceneHistory.ts"; import type { GizmoSettings } from "../features/viewport/GizmoSettings.ts"; +import type { SceneAction } from "../keybindings/sceneActions.ts"; import type { PresenceStore } from "../state/PresenceStore.ts"; import type { SelectionStore } from "../state/SelectionStore.ts"; @@ -39,6 +41,7 @@ export interface SceneEditing { presence: PresenceStore; gizmo: GizmoSettings; assetSlots: SceneAssetSlots; + shortcuts: ShortcutLabels; frameSelection(): void; previewEnvironment( patch: SceneEnvironmentPatch diff --git a/packages/editors/scene/src/features/inspector/ComponentReorder.ts b/packages/editors/scene/src/features/inspector/ComponentReorder.ts new file mode 100644 index 000000000..32ed5bdcf --- /dev/null +++ b/packages/editors/scene/src/features/inspector/ComponentReorder.ts @@ -0,0 +1,223 @@ +// Import Third-party Dependencies +import type { + ReactiveController, + ReactiveControllerHost +} from "lit"; +import { + SlidingReorder, + resolveDropIndex, + startPointerDragSession, + type ContainerEventMap, + type Folder, + type PointerDragSessionHandle +} from "@jolly-pixel/ui"; + +// Import Internal Dependencies +import { componentMove } from "./componentMove.ts"; + +type FolderDragEvent = CustomEvent; +type FolderReorderEvent = CustomEvent; + +// CONSTANTS +const kDragThreshold = 4; + +export interface ComponentReorderOptions { + folders(): Folder[]; + move(id: string, beforeId?: string): void; +} + +interface KeyboardGrab { + id: string; + order: string[]; +} + +export class ComponentReorder implements ReactiveController { + readonly #options: ComponentReorderOptions; + #ids: string[] | null = null; + #drag: PointerDragSessionHandle | null = null; + #grab: KeyboardGrab | null = null; + + constructor( + host: ReactiveControllerHost, + options: ComponentReorderOptions + ) { + host.addController(this); + this.#options = options; + } + + hostUpdated(): void { + if (this.#ids === null || sameOrder(this.#keys(), this.#ids)) { + return; + } + + this.#drag?.cancel(); + this.#release(); + } + + hostDisconnected(): void { + this.#drag?.cancel(); + this.#release(); + } + + onDrag = ( + event: FolderDragEvent + ) => { + event.stopPropagation(); + const { folder, event: pointer } = event.detail; + const handle = pointer.currentTarget; + const folders = this.#options.folders(); + const from = folders.indexOf(folder); + if ( + this.#ids !== null || + from === -1 || + !(handle instanceof HTMLElement) + ) { + return; + } + + const ids = folders.map(({ key }) => key); + let reorder: SlidingReorder | null = null; + let current: number | null = null; + this.#ids = ids; + this.#drag = startPointerDragSession({ + element: handle, + event: pointer, + threshold: kDragThreshold, + suppressClick: true, + onStart: () => { + reorder = new SlidingReorder({ + items: folders, + from, + axis: "y", + pointer: pointer.clientY + }); + }, + onMove: (clientX, clientY) => { + if (reorder === null) { + return; + } + + current = resolveDropIndex({ + position: clientY, + candidates: reorder.candidates, + current + }); + reorder.preview(current, clientX, clientY); + }, + onFinish: (result) => { + this.#drag = null; + this.#ids = null; + if (reorder === null) { + return; + } + + const move = result === "commit" && current !== null ? + componentMove(ids, from, current) : + null; + void reorder.settle(() => { + if (move !== null) { + this.#options.move(move.id, move.beforeId); + } + }); + } + }); + }; + + onReorder = ( + event: FolderReorderEvent + ) => { + event.stopPropagation(); + const { folder, command } = event.detail; + if (command === "start") { + this.#start(folder.key); + + return; + } + + const grab = this.#grab; + if (grab === null || grab.id !== folder.key) { + return; + } + + switch (command) { + case "up": + case "down": + this.#shift(grab, command === "up" ? -1 : 1); + break; + case "cancel": + this.#release(); + break; + case "finish": + this.#finish(grab); + break; + } + }; + + #start( + id: string + ): void { + if (this.#ids !== null) { + return; + } + + const ids = this.#keys(); + this.#ids = ids; + this.#grab = { + id, + order: [...ids] + }; + } + + #shift( + grab: KeyboardGrab, + step: -1 | 1 + ): void { + const { id, order } = grab; + const index = order.indexOf(id); + const next = index + step; + if (next < 0 || next >= order.length) { + return; + } + + [order[index], order[next]] = [order[next], order[index]]; + for (const folder of this.#options.folders()) { + folder.style.order = String(order.indexOf(folder.key)); + } + } + + #finish( + grab: KeyboardGrab + ): void { + const ids = this.#ids ?? []; + const from = ids.indexOf(grab.id); + const to = grab.order.indexOf(grab.id); + this.#release(); + + const move = componentMove(ids, from, to > from ? to + 1 : to); + if (move !== null) { + this.#options.move(move.id, move.beforeId); + } + } + + #release(): void { + if (this.#grab !== null) { + for (const folder of this.#options.folders()) { + folder.style.order = ""; + } + } + this.#grab = null; + this.#ids = null; + } + + #keys(): string[] { + return this.#options.folders().map(({ key }) => key); + } +} + +function sameOrder( + left: readonly string[], + right: readonly string[] +): boolean { + return left.length === right.length && + left.every((key, index) => key === right[index]); +} diff --git a/packages/editors/scene/src/features/inspector/InspectorPanel.ts b/packages/editors/scene/src/features/inspector/InspectorPanel.ts index 5c661a249..b9758a28f 100644 --- a/packages/editors/scene/src/features/inspector/InspectorPanel.ts +++ b/packages/editors/scene/src/features/inspector/InspectorPanel.ts @@ -3,35 +3,34 @@ import { LitElement, css, html, + nothing, type TemplateResult } from "lit"; +import { repeat } from "lit/directives/repeat.js"; import type { SceneActorJSON, SceneComponentJSON, + SceneDocument, SceneTransformField } from "@jolly-pixel/asset.scene/client"; import { ContextMenuController, + FolderReorder, MenuSession, SubscriptionController, menuPointBelow, type ContextMenu, + type Folder, type JollyChangeDetail, + type JollyOption, type MenuEntry } from "@jolly-pixel/ui"; import "@jolly-pixel/ui/elements/button"; -import "@jolly-pixel/ui/elements/checkbox"; -import "@jolly-pixel/ui/elements/color"; +import "@jolly-pixel/ui/elements/button-group"; import "@jolly-pixel/ui/elements/context-menu"; import "@jolly-pixel/ui/elements/folder"; -import "@jolly-pixel/ui/elements/number"; -import "@jolly-pixel/ui/elements/property-row"; -import "@jolly-pixel/ui/elements/select"; import "@jolly-pixel/ui/elements/separator"; -import "@jolly-pixel/ui/elements/text"; -import "@jolly-pixel/ui/elements/vector2"; import "@jolly-pixel/ui/elements/vector3"; -import "@jolly-pixel/ui/elements/vector4"; // Import Internal Dependencies import "../../app/sceneIcons.ts"; @@ -40,34 +39,52 @@ import { SCENE_COMPONENTS } from "../../components/sceneComponents.ts"; import type { SceneEditing } from "../../editing/SceneEditing.ts"; -import { - axisValues, - renderComponentBody -} from "./componentBody.ts"; +import { axisValues } from "./axisValues.ts"; +import { renderComponentBody } from "./componentBody.ts"; type ChangeEvent = CustomEvent>; // CONSTANTS const kAxes = ["x", "y", "z"] as const; -const kTransformRows: readonly { field: SceneTransformField; label: string; step: number; }[] = [ +const kTransformOptions: JollyOption[] = [ { - field: "position", - label: "Position", - step: 0.1 + value: "position", + label: "Position" }, { - field: "rotation", - label: "Rotation", - step: 1 + value: "rotation", + label: "Rotation" }, { - field: "scale", - label: "Scale", - step: 0.1 + value: "scale", + label: "Scale" } ]; +const kTransformSteps: Record = { + position: 0.1, + rotation: 1, + scale: 0.1 +}; export class InspectorPanel extends LitElement { + #transformField: SceneTransformField = "position"; + #announcement = ""; + + #reorder = new FolderReorder(this, { + folders: () => [ + ...this.renderRoot.querySelectorAll(".components > jolly-folder") + ], + zone: () => this.#components(), + commit: (order, folder) => this.#editing.current?.document.moveComponent( + folder.key, + order[order.indexOf(folder) + 1]?.key + ), + announce: (message) => { + this.#announcement = message; + this.requestUpdate(); + } + }); + #editing = new SubscriptionController(this, (editing) => { const repaint = (): void => this.requestUpdate(); @@ -91,8 +108,9 @@ export class InspectorPanel extends LitElement { gap: var(--jolly-row-gap, 4px); padding: var(--jolly-space-2, 8px); overflow: auto; - + --jolly-label-width: 12ch; + --jolly-field-inset-start: 0px; } .empty { @@ -100,8 +118,22 @@ export class InspectorPanel extends LitElement { padding: var(--jolly-space-2, 8px); } - .slot, - .property-head { + .components { + display: flex; + flex-direction: column; + gap: var(--jolly-row-gap, 4px); + } + + .live-region { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .slot { display: flex; align-items: center; gap: var(--jolly-space-1, 4px); @@ -116,13 +148,6 @@ export class InspectorPanel extends LitElement { white-space: nowrap; } - .property { - display: flex; - flex-direction: column; - gap: var(--jolly-row-gap, 4px); - padding-block: var(--jolly-space-1, 4px); - } - .unknown { margin: 0; overflow: auto; @@ -146,115 +171,135 @@ export class InspectorPanel extends LitElement { } return html` - ${this.#renderActor(editing, actor)} - - ${actor.components.map((component, index) => this.#renderComponent( - editing, - actor, - component, - index - ))} - this.#openAddComponent(event, editing, actor.id)} - >Add component + ${this.#renderActor(editing.document, actor)} + + this.#openAddComponent(event, editing, actor.id)} + > + + ${actor.components.length === 0 ? nothing : html` +
+ ${repeat( + actor.components, + (component) => component.id, + (component) => this.#renderComponent( + editing, + component, + actor.components.length > 1 + ) + )} +
+ `} + ${this.#announcement} `; } #renderActor( - editing: SceneEditing, + document: SceneDocument, + actor: SceneActorJSON + ): TemplateResult { + return html` + + document.changeVisibility(actor.id, !actor.visible)} + > + ${this.#renderTransform(document, actor)} + + `; + } + + #renderTransform( + document: SceneDocument, actor: SceneActorJSON ): TemplateResult { - const { document } = editing; + const field = this.#transformField; + const current = actor.transform[field]; return html` - ) => { - const name = event.detail.value.trim(); - if (name !== "") { - document.rename(actor.id, name); - } + ) => { + this.#transformField = event.detail.value; + this.requestUpdate(); + }} + > + >) => { + const [x, y, z] = axisValues( + event.detail.value, + kAxes, + kAxes.map((axis) => current[axis]) + ); + document.transform(actor.id, { [field]: { x, y, z } }); }} - > - ) => document.changeVisibility( - actor.id, - event.detail.value - )} - > - ${kTransformRows.map(({ field, label, step }) => html` - >) => { - const current = actor.transform[field]; - const [x, y, z] = axisValues( - event.detail.value, - kAxes, - kAxes.map((axis) => current[axis]) - ); - document.transform(actor.id, { [field]: { x, y, z } }); - }} - > - `)} + > `; } #renderComponent( editing: SceneEditing, - actor: SceneActorJSON, component: SceneComponentJSON, - index: number + reorderable: boolean ): TemplateResult { - const { document } = editing; - const siblings = actor.components; - return html` - document.moveComponent(component.id, siblings[index - 1]?.id)} - > - document.moveComponent(component.id, siblings[index + 2]?.id)} - > document.removeComponent(component.id)} + @click=${() => editing.document.removeComponent(component.id)} > ${renderComponentBody(component, editing)} `; } + #components(): HTMLElement { + const components = this.renderRoot.querySelector(".components"); + if (components === null) { + throw new Error("InspectorPanel: the component list is not rendered."); + } + + return components; + } + #contextMenu(): ContextMenu { const menu = this.renderRoot.querySelector("jolly-context-menu"); if (menu === null) { diff --git a/packages/editors/scene/src/features/inspector/ScriptPropertiesEditor.ts b/packages/editors/scene/src/features/inspector/ScriptPropertiesEditor.ts new file mode 100644 index 000000000..143731cb5 --- /dev/null +++ b/packages/editors/scene/src/features/inspector/ScriptPropertiesEditor.ts @@ -0,0 +1,399 @@ +// Import Third-party Dependencies +import { + LitElement, + css, + html, + nothing, + type TemplateResult +} from "lit"; +import { live } from "lit/directives/live.js"; +import { repeat } from "lit/directives/repeat.js"; +import type { + JSONObject, + JSONValue +} from "@jolly-pixel/asset.scene/client"; +import { + SCENE_PROPERTY_TYPES, + type ScenePropertyType +} from "@jolly-pixel/engine"; +import type { + JollyChangeDetail, + JollyOption +} from "@jolly-pixel/ui"; +import "@jolly-pixel/ui/elements/button"; +import "@jolly-pixel/ui/elements/checkbox"; +import "@jolly-pixel/ui/elements/color"; +import "@jolly-pixel/ui/elements/number"; +import "@jolly-pixel/ui/elements/select"; +import "@jolly-pixel/ui/elements/text"; +import "@jolly-pixel/ui/elements/vector2"; +import "@jolly-pixel/ui/elements/vector3"; +import "@jolly-pixel/ui/elements/vector4"; + +// Import Internal Dependencies +import { + addProperty, + convertProperty, + listText, + parseList, + removeProperty, + renameProperty, + scriptProperties, + type ScriptListType, + type ScriptProperty +} from "../../components/scriptProperties.ts"; +import { axisValues } from "./axisValues.ts"; + +export interface ScriptPropertiesChangeDetail { + properties: JSONObject; +} + +interface KeyError { + row: string | null; + message: string; +} + +interface RowEdit { + rename: (key: string) => void; + retype: (type: ScenePropertyType) => void; + setValue: (value: JSONValue) => void; +} + +type ChangeEvent = CustomEvent>; + +// CONSTANTS +const kChangeEvent = "script-properties-change"; +const kVectorAxes = ["x", "y", "z", "w"] as const; +const kWideTypes: ReadonlySet = new Set([ + "Vector2", + "Vector3", + "Vector4", + "Color", + "number[]", + "string[]", + "boolean[]" +]); +const kTypeOptions: JollyOption[] = SCENE_PROPERTY_TYPES.map( + (type) => { + return { + value: type, + label: type[0].toUpperCase() + type.slice(1) + }; + } +); + +export class ScriptPropertiesEditor extends LitElement { + #properties: Readonly = {}; + #draft = emptyDraft(); + #keyError: KeyError | null = null; + + static override styles = css` + :host { + display: grid; + grid-template-columns: minmax(0, 1fr) 88px minmax(0, 1fr) auto; + align-items: center; + gap: var(--jolly-row-gap, 4px) var(--jolly-space-1, 4px); + padding-inline: var(--jolly-space-1, 4px); + + --jolly-field-inset-end: 0px; + } + + .property { + display: contents; + } + + .property > * { + min-width: 0; + } + + .remove { + grid-column: 4; + } + + .wide-value { + grid-column: 1 / -1; + } + + .draft .value { + grid-column: 3 / -1; + } + `; + + set properties( + value: Readonly + ) { + this.#properties = value; + this.requestUpdate(); + } + + override render(): TemplateResult { + return html` + ${repeat( + scriptProperties(this.#properties), + (property) => property.key, + (property) => this.#renderProperty(property) + )} + ${this.#renderDraft()} + `; + } + + #renderProperty( + property: ScriptProperty + ): TemplateResult { + const setValue = (value: JSONValue): void => this.#write({ + ...this.#properties, + [property.key]: value + }); + + return this.#renderRow( + property, + property.key, + { + rename: (key) => this.#rename(property.key, key), + retype: (type) => setValue(convertProperty(property.value, type)), + setValue + } + ); + } + + #renderDraft(): TemplateResult { + const draft = this.#draft; + + return this.#renderRow( + draft, + null, + { + rename: (key) => this.#add(key), + retype: (type) => this.#editDraft({ + ...draft, + type, + value: convertProperty(draft.value, type) + }), + setValue: (value) => this.#editDraft({ + ...draft, + value + }) + } + ); + } + + #renderRow( + property: ScriptProperty, + row: string | null, + edit: RowEdit + ): TemplateResult { + const error = this.#keyError?.row === row ? this.#keyError.message : null; + const wide = kWideTypes.has(property.type); + const value = renderValue(property, edit.setValue); + + return html` +
+ ) => edit.rename(event.detail.value)} + > + ) => edit.retype(event.detail.value)} + > + ${wide ? nothing : html`
${value}
`} + ${row === null ? nothing : html` + this.#write(removeProperty(this.#properties, row))} + > + `} + ${wide ? html`
${value}
` : nothing} +
+ `; + } + + #add( + key: string + ): void { + const trimmed = key.trim(); + if (trimmed === "") { + this.#editDraft({ + ...this.#draft, + key: "" + }); + + return; + } + if (Object.hasOwn(this.#properties, trimmed)) { + this.#draft = { + ...this.#draft, + key: trimmed + }; + this.#refuseKey(null); + + return; + } + + const { value } = this.#draft; + this.#draft = emptyDraft(); + this.#write(addProperty(this.#properties, trimmed, value)); + } + + #rename( + from: string, + to: string + ): void { + const trimmed = to.trim(); + if (trimmed === from) { + return; + } + if (Object.hasOwn(this.#properties, trimmed)) { + this.#refuseKey(from); + + return; + } + + this.#write(renameProperty(this.#properties, from, trimmed)); + } + + #refuseKey( + row: string | null + ): void { + this.#keyError = { + row, + message: "Already exists" + }; + this.requestUpdate(); + } + + #editDraft( + draft: ScriptProperty + ): void { + this.#draft = draft; + this.#keyError = null; + this.requestUpdate(); + } + + #write( + properties: JSONObject + ): void { + this.#keyError = null; + this.dispatchEvent(new CustomEvent( + kChangeEvent, + { + detail: { properties } + } + )); + } +} + +function emptyDraft(): ScriptProperty { + return { + key: "", + type: "number", + value: 0 + }; +} + +function renderValue( + property: ScriptProperty, + setValue: (value: JSONValue) => void +): TemplateResult { + const { type, value } = property; + switch (type) { + case "number": + return html` + ) => setValue(event.detail.value)} + > + `; + case "boolean": + return html` + ) => setValue(event.detail.value)} + > + `; + case "Color": + return html` + ) => setValue(event.detail.value)} + > + `; + case "string": + return html` + ) => setValue(event.detail.value)} + > + `; + case "Vector2": + case "Vector3": + case "Vector4": + return renderVector(type, value, setValue); + case "number[]": + case "string[]": + case "boolean[]": + return renderList(type, value, setValue); + } +} + +function renderList( + type: ScriptListType, + value: JSONValue, + setValue: (value: JSONValue) => void +): TemplateResult { + return html` + ) => setValue(parseList(event.detail.value, type))} + > + `; +} + +function renderVector( + type: "Vector2" | "Vector3" | "Vector4", + value: JSONValue, + setValue: (value: JSONValue) => void +): TemplateResult { + const numbers = (Array.isArray(value) ? value : []).map(Number); + const axes = kVectorAxes.slice(0, Number(type.at(-1))); + const vector = Object.fromEntries( + axes.map((axis, index) => [axis, numbers[index] ?? 0]) + ); + function commit(event: ChangeEvent>): void { + setValue(axisValues(event.detail.value, axes, numbers)); + } + + switch (type) { + case "Vector2": + return html``; + case "Vector3": + return html``; + case "Vector4": + return html``; + } +} + +customElements.define("jolly-scene-script-properties", ScriptPropertiesEditor); + +declare global { + interface HTMLElementTagNameMap { + "jolly-scene-script-properties": ScriptPropertiesEditor; + } +} diff --git a/packages/editors/scene/src/features/inspector/axisValues.ts b/packages/editors/scene/src/features/inspector/axisValues.ts new file mode 100644 index 000000000..f1ccfbf73 --- /dev/null +++ b/packages/editors/scene/src/features/inspector/axisValues.ts @@ -0,0 +1,11 @@ +export function axisValues( + value: Readonly>, + axes: readonly string[], + current: readonly number[] +): number[] { + return axes.map((axis, index) => { + const next = value[axis]; + + return typeof next === "number" ? next : current[index] ?? 0; + }); +} diff --git a/packages/editors/scene/src/features/inspector/componentBody.ts b/packages/editors/scene/src/features/inspector/componentBody.ts index c1d3bc6c4..c8abf27d2 100644 --- a/packages/editors/scene/src/features/inspector/componentBody.ts +++ b/packages/editors/scene/src/features/inspector/componentBody.ts @@ -10,15 +10,8 @@ import type { JSONValue, SceneComponentJSON } from "@jolly-pixel/asset.scene/client"; -import { - SCENE_PROPERTY_TYPES, - Systems, - type ScenePropertyType -} from "@jolly-pixel/engine"; -import type { - JollyChangeDetail, - JollyOption -} from "@jolly-pixel/ui"; +import { Systems } from "@jolly-pixel/engine"; +import type { JollyChangeDetail } from "@jolly-pixel/ui"; import "@jolly-pixel/ui/elements/button"; import "@jolly-pixel/ui/elements/checkbox"; import "@jolly-pixel/ui/elements/color"; @@ -27,9 +20,6 @@ import "@jolly-pixel/ui/elements/property-row"; import "@jolly-pixel/ui/elements/select"; import "@jolly-pixel/ui/elements/separator"; import "@jolly-pixel/ui/elements/text"; -import "@jolly-pixel/ui/elements/vector2"; -import "@jolly-pixel/ui/elements/vector3"; -import "@jolly-pixel/ui/elements/vector4"; // Import Internal Dependencies import { @@ -40,18 +30,9 @@ import { componentAssetSlots, sceneComponent } from "../../components/sceneComponents.ts"; -import { - convertProperty, - freePropertyKey, - listText, - parseList, - removeProperty, - renameProperty, - scriptProperties, - type ScriptListType, - type ScriptProperty -} from "../../components/scriptProperties.ts"; import type { SceneEditing } from "../../editing/SceneEditing.ts"; +import "./ScriptPropertiesEditor.ts"; +import type { ScriptPropertiesChangeDetail } from "./ScriptPropertiesEditor.ts"; export type ComponentBodyContext = Pick< SceneEditing, @@ -60,17 +41,6 @@ export type ComponentBodyContext = Pick< type ChangeEvent = CustomEvent>; -// CONSTANTS -const kVectorAxes = ["x", "y", "z", "w"] as const; -const kScriptTypeOptions: JollyOption[] = SCENE_PROPERTY_TYPES.map( - (type) => { - return { - value: type, - label: type[0].toUpperCase() + type.slice(1) - }; - } -); - export function renderComponentBody( entry: SceneComponentJSON, context: ComponentBodyContext @@ -96,18 +66,6 @@ export function renderComponentBody( `; } -export function axisValues( - value: Readonly>, - axes: readonly string[], - current: readonly number[] -): number[] { - return axes.map((axis, index) => { - const next = value[axis]; - - return typeof next === "number" ? next : current[index] ?? 0; - }); -} - function renderRow( inspected: InspectedRow, commit: (next: JSONValue) => void @@ -216,154 +174,15 @@ function renderProperties( properties: Readonly, write: (next: JSONObject) => void ): TemplateResult { - function add(): void { - write({ - ...properties, - [freePropertyKey(properties)]: 0 - }); + function change(event: CustomEvent): void { + write(event.detail.properties); } return html` - ${scriptProperties(properties).map((property) => renderScriptProperty( - property, - properties, - write - ))} - Add property - `; -} - -function renderScriptProperty( - property: ScriptProperty, - properties: Readonly, - write: (next: JSONObject) => void -): TemplateResult { - function setValue(value: JSONValue): void { - write({ - ...properties, - [property.key]: value - }); - } - - return html` -
-
- ) => { - const key = event.detail.value.trim(); - if (key !== "") { - write(renameProperty(properties, property.key, key)); - } - }} - > - ) => setValue( - convertProperty(property.value, event.detail.value) - )} - > - write(removeProperty(properties, property.key))} - > -
- ${renderPropertyValue(property, setValue)} -
- `; -} - -function renderPropertyValue( - property: ScriptProperty, - setValue: (value: JSONValue) => void -): TemplateResult { - const { type, value } = property; - switch (type) { - case "number": - return html` - ) => setValue(event.detail.value)} - > - `; - case "boolean": - return html` - ) => setValue(event.detail.value)} - > - `; - case "Color": - return html` - ) => setValue(event.detail.value)} - > - `; - case "string": - return html` - ) => setValue(event.detail.value)} - > - `; - case "Vector2": - case "Vector3": - case "Vector4": - return renderVector(type, value, setValue); - case "number[]": - case "string[]": - case "boolean[]": - return renderList(type, value, setValue); - } -} - -function renderList( - type: ScriptListType, - value: JSONValue, - setValue: (value: JSONValue) => void -): TemplateResult { - return html` - ) => setValue(parseList(event.detail.value, type))} - > + `; } - -function renderVector( - type: "Vector2" | "Vector3" | "Vector4", - value: JSONValue, - setValue: (value: JSONValue) => void -): TemplateResult { - const numbers = (Array.isArray(value) ? value : []).map(Number); - const axes = kVectorAxes.slice(0, Number(type.at(-1))); - const vector = Object.fromEntries( - axes.map((axis, index) => [axis, numbers[index] ?? 0]) - ); - function commit(event: ChangeEvent>): void { - setValue(axisValues(event.detail.value, axes, numbers)); - } - - switch (type) { - case "Vector2": - return html``; - case "Vector3": - return html``; - case "Vector4": - return html``; - } -} diff --git a/packages/editors/scene/src/features/inspector/componentMove.ts b/packages/editors/scene/src/features/inspector/componentMove.ts new file mode 100644 index 000000000..09e5d5854 --- /dev/null +++ b/packages/editors/scene/src/features/inspector/componentMove.ts @@ -0,0 +1,25 @@ +export interface ComponentMove { + id: string; + beforeId?: string; +} + +export function componentMove( + ids: readonly string[], + from: number, + insertion: number +): ComponentMove | null { + const id = ids[from]; + const slot = Math.min(Math.max(insertion, 0), ids.length); + if (id === undefined || slot === from || slot === from + 1) { + return null; + } + + const beforeId = ids[slot]; + + return beforeId === undefined ? + { id } : + { + id, + beforeId + }; +} diff --git a/packages/editors/scene/src/features/viewport/SceneToolbar.styles.ts b/packages/editors/scene/src/features/viewport/SceneToolbar.styles.ts new file mode 100644 index 000000000..4118551ed --- /dev/null +++ b/packages/editors/scene/src/features/viewport/SceneToolbar.styles.ts @@ -0,0 +1,31 @@ +// Import Third-party Dependencies +import { css } from "lit"; + +export const sceneToolbarStyles = css` + :host { + display: inline-flex; + } + + .group { + display: flex; + align-items: center; + } + + .divided::before { + content: ""; + flex: 0 0 auto; + width: 1px; + height: 22px; + margin-inline: 6px; + background: var(--jolly-divider); + } + + jolly-tool-button { + --jolly-tool-button-size: 30px; + --jolly-tool-button-gap: 10px; + } + + jolly-tool-button::part(button) { + border-radius: var(--jolly-radius-sm, 2px); + } +`; diff --git a/packages/editors/scene/src/features/viewport/SceneToolbar.ts b/packages/editors/scene/src/features/viewport/SceneToolbar.ts new file mode 100644 index 000000000..84f8ed6b8 --- /dev/null +++ b/packages/editors/scene/src/features/viewport/SceneToolbar.ts @@ -0,0 +1,179 @@ +// Import Third-party Dependencies +import { + LitElement, + html, + nothing, + type TemplateResult +} from "lit"; +import { SubscriptionController } from "@jolly-pixel/ui"; +import "@jolly-pixel/ui/elements/rail"; +import "@jolly-pixel/ui/elements/tool-button"; + +// Import Internal Dependencies +import "../../app/sceneIcons.ts"; +import type { SceneEditing } from "../../editing/SceneEditing.ts"; +import type { SceneAction } from "../../keybindings/sceneActions.ts"; +import { SCENE_HISTORY_SCOPE } from "../history/sceneHistory.ts"; +import type { GizmoMode } from "./GizmoSettings.ts"; +import { sceneToolbarStyles } from "./SceneToolbar.styles.ts"; + +// CONSTANTS +const kModes: readonly { + mode: GizmoMode; + icon: string; + label: string; + action: SceneAction; +}[] = [ + { + mode: "translate", + icon: "gizmo-translate", + label: "Move", + action: "gizmo.translate" + }, + { + mode: "rotate", + icon: "gizmo-rotate", + label: "Rotate", + action: "gizmo.rotate" + }, + { + mode: "scale", + icon: "gizmo-scale", + label: "Scale", + action: "gizmo.scale" + } +]; + +export class SceneToolbar extends LitElement { + #editing = new SubscriptionController(this, (editing) => { + const repaint = (): void => this.requestUpdate(); + + return [ + editing.gizmo.subscribe("change", repaint), + editing.history.subscribe("change", repaint), + editing.selection.subscribe("change", repaint), + editing.shortcuts.subscribe(repaint) + ]; + }); + + static override styles = sceneToolbarStyles; + + attach( + editing: SceneEditing + ): void { + this.#editing.attach(editing); + } + + override render(): TemplateResult | typeof nothing { + const editing = this.#editing.current; + if (editing === null) { + return nothing; + } + + return html` + + ${this.#renderHistory(editing)} + ${this.#renderGizmo(editing)} + ${this.#renderView(editing)} + + `; + } + + #renderHistory( + editing: SceneEditing + ): TemplateResult { + const { history, shortcuts } = editing; + const { undoCount, redoCount } = history.state(SCENE_HISTORY_SCOPE); + + return html` +
+ history.undo(SCENE_HISTORY_SCOPE)} + > + history.redo(SCENE_HISTORY_SCOPE)} + > +
+ `; + } + + #renderGizmo( + editing: SceneEditing + ): TemplateResult { + const { gizmo, shortcuts } = editing; + const local = gizmo.space === "local"; + + return html` +
+ ${kModes.map(({ mode, icon, label, action }) => html` + { + gizmo.mode = mode; + }} + > + `)} +
+
+ gizmo.toggleSpace()} + > +
+ `; + } + + #renderView( + editing: SceneEditing + ): TemplateResult { + const { selection, shortcuts } = editing; + + return html` +
+ editing.frameSelection()} + > +
+ `; + } +} + +customElements.define("jolly-scene-toolbar", SceneToolbar); + +declare global { + interface HTMLElementTagNameMap { + "jolly-scene-toolbar": SceneToolbar; + } +} diff --git a/packages/editors/scene/src/features/viewport/ViewToolbar.ts b/packages/editors/scene/src/features/viewport/ViewToolbar.ts deleted file mode 100644 index 35793e1de..000000000 --- a/packages/editors/scene/src/features/viewport/ViewToolbar.ts +++ /dev/null @@ -1,109 +0,0 @@ -// Import Third-party Dependencies -import { - LitElement, - css, - html, - nothing, - type TemplateResult -} from "lit"; -import { SubscriptionController } from "@jolly-pixel/ui"; -import "@jolly-pixel/ui/elements/tool-button"; -import "@jolly-pixel/ui/elements/toolbar"; - -// Import Internal Dependencies -import "../../app/sceneIcons.ts"; -import type { SceneEditing } from "../../editing/SceneEditing.ts"; -import { SCENE_HISTORY_SCOPE } from "../history/sceneHistory.ts"; -import type { GizmoMode } from "./GizmoSettings.ts"; - -// CONSTANTS -const kModes: readonly { mode: GizmoMode; icon: string; label: string; }[] = [ - { - mode: "translate", - icon: "gizmo-translate", - label: "Move" - }, - { - mode: "rotate", - icon: "gizmo-rotate", - label: "Rotate" - }, - { - mode: "scale", - icon: "gizmo-scale", - label: "Scale" - } -]; - -export class ViewToolbar extends LitElement { - #editing = new SubscriptionController(this, (editing) => { - const repaint = (): void => this.requestUpdate(); - - return [ - editing.gizmo.subscribe("change", repaint), - editing.history.subscribe("change", repaint) - ]; - }); - - static override styles = css` - :host { - display: inline-flex; - } - `; - - attach( - editing: SceneEditing - ): void { - this.#editing.attach(editing); - } - - override render(): TemplateResult | typeof nothing { - const editing = this.#editing.current; - if (editing === null) { - return nothing; - } - - const { gizmo, history } = editing; - const state = history.state(SCENE_HISTORY_SCOPE); - - return html` - - ${kModes.map(({ mode, icon, label }) => html` - { - gizmo.mode = mode; - }} - > - `)} - gizmo.toggleSpace()} - > - history.undo(SCENE_HISTORY_SCOPE)} - > - history.redo(SCENE_HISTORY_SCOPE)} - > - - `; - } -} - -customElements.define("jolly-scene-view-toolbar", ViewToolbar); - -declare global { - interface HTMLElementTagNameMap { - "jolly-scene-view-toolbar": ViewToolbar; - } -} diff --git a/packages/editors/scene/src/index.ts b/packages/editors/scene/src/index.ts index 0e74bfffe..eeea4719e 100644 --- a/packages/editors/scene/src/index.ts +++ b/packages/editors/scene/src/index.ts @@ -13,7 +13,7 @@ import "./app/sceneIcons.ts"; import "./features/hierarchy/HierarchyPanel.ts"; import "./features/inspector/InspectorPanel.ts"; import "./features/environment/ScenePanel.ts"; -import "./features/viewport/ViewToolbar.ts"; +import "./features/viewport/SceneToolbar.ts"; import { SceneEditor } from "./boot/SceneEditor.ts"; declare global { diff --git a/packages/editors/scene/src/scene/SceneEditorScene.ts b/packages/editors/scene/src/scene/SceneEditorScene.ts index 67016deb0..ab98597db 100644 --- a/packages/editors/scene/src/scene/SceneEditorScene.ts +++ b/packages/editors/scene/src/scene/SceneEditorScene.ts @@ -3,7 +3,11 @@ import { OrbitFlyCamera, Systems } from "@jolly-pixel/engine"; -import type { KeyBindingSettings } from "@jolly-pixel/controls"; +import { + ShortcutLabels, + type KeyBindingSettings, + type KeyboardLayoutStore +} from "@jolly-pixel/controls"; import type { SceneDocument, SceneRoom @@ -66,6 +70,7 @@ export interface SceneEditorSceneOptions { presence: PresenceStore; preferences: Preferences; keyBindings: KeyBindingSettings; + keyboardLayout: KeyboardLayoutStore; } export class SceneEditorScene extends Systems.Scene { @@ -199,6 +204,10 @@ export class SceneEditorScene extends Systems.Scene { presence, gizmo, assetSlots, + shortcuts: new ShortcutLabels( + this.#options.keyboardLayout, + this.#options.keyBindings + ), frameSelection, previewEnvironment: (patch) => binding.previewEnvironment(patch), teleportToPeer: (clientId) => { diff --git a/packages/editors/scene/test/components/scriptProperties.spec.ts b/packages/editors/scene/test/components/scriptProperties.spec.ts index 6ca448661..75eb2f528 100644 --- a/packages/editors/scene/test/components/scriptProperties.spec.ts +++ b/packages/editors/scene/test/components/scriptProperties.spec.ts @@ -4,8 +4,8 @@ import assert from "node:assert/strict"; // Import Internal Dependencies import { + addProperty, convertProperty, - freePropertyKey, listText, parseList, removeProperty, @@ -51,12 +51,23 @@ describe("scriptProperties", () => { test("renames a key in place and refuses a key that already exists", () => { const properties = { speed: 1, jump: 2, name: "a" }; - assert.deepEqual(Object.keys(renameProperty(properties, "jump", "height")), ["speed", "height", "name"]); + assert.deepEqual( + Object.keys(renameProperty(properties, "jump", " height ")), + ["speed", "height", "name"] + ); assert.deepEqual(renameProperty(properties, "jump", "speed"), properties); }); - test("finds a free key and removes one", () => { - assert.strictEqual(freePropertyKey({ property: 1, property2: 2 }), "property3"); + test("removes a property whose key is cleared", () => { + assert.deepEqual(renameProperty({ a: 1, b: 2 }, "a", " "), { b: 2 }); assert.deepEqual(removeProperty({ a: 1, b: 2 }, "a"), { b: 2 }); }); + + test("adds a property under a trimmed key unless it is blank or taken", () => { + const properties = { speed: 1 }; + + assert.deepEqual(addProperty(properties, " up ", [0, 1, 0]), { speed: 1, up: [0, 1, 0] }); + assert.deepEqual(addProperty(properties, "speed", 5), properties); + assert.deepEqual(addProperty(properties, " ", 5), properties); + }); }); diff --git a/packages/editors/scene/test/e2e/smoke.e2e.ts b/packages/editors/scene/test/e2e/smoke.e2e.ts index eb381647d..09af5bffb 100644 --- a/packages/editors/scene/test/e2e/smoke.e2e.ts +++ b/packages/editors/scene/test/e2e/smoke.e2e.ts @@ -1,7 +1,6 @@ // Import Third-party Dependencies import { dialog, - textField, treeRow } from "@jolly-pixel/e2e"; import { pressAt } from "@jolly-pixel/e2e/editor"; @@ -14,9 +13,11 @@ import { import { addActor, addComponent, + editTransform, hierarchy, + inspectedActor, inspector, - transformAxis + sceneTool } from "./support/panels.ts"; import { actorPoint, @@ -35,15 +36,36 @@ test("the inspector edits the actor added from the hierarchy", async({ page }) = await addActor(page, ["Primitive", "Box"]); await expect(treeRow(page, "Box")).toHaveAttribute("aria-selected", "true"); - await expect(textField(inspector(page), "Name")).toHaveValue("Box"); + await expect(inspectedActor(page)).toHaveAttribute("label", "Box"); await expect(inspector(page).locator("jolly-folder[label=\"Mesh\"]")).toBeVisible(); - const x = transformAxis(page, "Position", "X"); - await x.fill("2"); - await x.press("Enter"); + await editTransform(page, "Scale", "Y", "2"); + await expect.poll(async() => (await findActor(page, "Box"))?.transform.scale) + .toEqual({ x: 1, y: 2, z: 1 }); - await expect.poll(async() => (await findActor(page, "Box"))?.transform.position) - .toEqual({ x: 2, y: 0, z: 0 }); + const visible = inspectedActor(page).getByRole("button", { name: "Visible" }); + await expect(visible).toHaveAttribute("aria-pressed", "true"); + await visible.click(); + await expect(visible).toHaveAttribute("aria-pressed", "false"); + await expect.poll(async() => (await findActor(page, "Box"))?.visible) + .toBe(false); +}); + +test("the toolbar undoes and redoes the actor added from the hierarchy", async({ page }) => { + const undo = sceneTool(page, "undo"); + const redo = sceneTool(page, "redo"); + await expect(undo).toHaveAttribute("disabled"); + + await addActor(page, ["Primitive", "Box"]); + await expect(treeRow(page, "Box")).toBeVisible(); + + await undo.click(); + await expect(treeRow(page, "Box")).toHaveCount(0); + await expect(undo).toHaveAttribute("disabled"); + + await redo.click(); + await expect(treeRow(page, "Box")).toBeVisible(); + await expect(redo).toHaveAttribute("disabled"); }); test("a click in the viewport selects an actor and the gizmo moves it in one undo step", async({ page }) => { @@ -66,7 +88,7 @@ test("a click in the viewport selects an actor and the gizmo moves it in one und .toEqual({ x: 0, y: 0, z: 0 }); }); -test("a voxel map component takes the map picked from the catalog", async({ page, target }) => { +test("a voxel map component takes the picked map, then moves above the light", async({ page, target }) => { await treeRow(page, "Sky").click(); await addComponent(page, "Voxel map…"); @@ -80,4 +102,15 @@ test("a voxel map component takes the map picked from the catalog", async({ page await expect(component.getByText(target.mapName, { exact: true })).toBeVisible(); expect((await findActor(page, "Sky"))?.components.map(({ type }) => type)) .toEqual(["Light", "VoxelMap"]); + + const grip = component.locator(".grip"); + await grip.focus(); + await grip.press("Space"); + await grip.press("ArrowUp"); + await grip.press("Space"); + await expect.poll(async() => (await findActor(page, "Sky"))?.components.map(({ type }) => type)) + .toEqual(["VoxelMap", "Light"]); + await expect(inspector(page).locator("jolly-folder[reorderable]").first()) + .toHaveAttribute("label", "VoxelMap"); + await expect(grip).toHaveAttribute("aria-pressed", "false"); }); diff --git a/packages/editors/scene/test/e2e/support/panels.ts b/packages/editors/scene/test/e2e/support/panels.ts index deb7cbbb7..bd9c18bd0 100644 --- a/packages/editors/scene/test/e2e/support/panels.ts +++ b/packages/editors/scene/test/e2e/support/panels.ts @@ -17,14 +17,35 @@ export function inspector( return page.locator("jolly-scene-inspector"); } -export function transformAxis( +export function sceneTool( page: Page, - field: "Position" | "Rotation" | "Scale", - axis: "X" | "Y" | "Z" + tool: string +): Locator { + return page.locator( + `jolly-scene-toolbar jolly-tool-button[data-tool="${tool}"]` + ); +} + +export function inspectedActor( + page: Page ): Locator { - return inspector(page) - .locator(`jolly-vector3[label="${field}"]`) + return inspector(page).locator("jolly-folder[key=\"actor\"]"); +} + +export async function editTransform( + page: Page, + field: "Position" | "Rotation" | "Scale", + axis: "X" | "Y" | "Z", + value: string +): Promise { + const actor = inspectedActor(page); + await actor.getByRole("radio", { name: field }).click(); + + const input = actor + .locator("jolly-vector3") .getByRole("textbox", { name: axis }); + await input.fill(value); + await input.press("Enter"); } export async function chooseMenuPath( diff --git a/packages/editors/scene/test/features/componentMove.spec.ts b/packages/editors/scene/test/features/componentMove.spec.ts new file mode 100644 index 000000000..244073abd --- /dev/null +++ b/packages/editors/scene/test/features/componentMove.spec.ts @@ -0,0 +1,28 @@ +// Import Node.js Dependencies +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; + +// Import Internal Dependencies +import { componentMove } from "../../src/features/inspector/componentMove.ts"; + +// CONSTANTS +const kIds = ["a", "b", "c", "d"]; + +describe("componentMove", () => { + test("places the component before the one at the insertion slot", () => { + assert.deepEqual(componentMove(kIds, 0, 3), { id: "a", beforeId: "d" }); + assert.deepEqual(componentMove(kIds, 3, 1), { id: "d", beforeId: "b" }); + assert.deepEqual(componentMove(kIds, 2, 0), { id: "c", beforeId: "a" }); + }); + + test("appends the component when the slot is past the last one", () => { + assert.deepEqual(componentMove(kIds, 1, 4), { id: "b" }); + assert.deepEqual(componentMove(kIds, 1, 9), { id: "b" }); + }); + + test("moves nothing when the slot is on either side of the component", () => { + assert.strictEqual(componentMove(kIds, 1, 1), null); + assert.strictEqual(componentMove(kIds, 1, 2), null); + assert.strictEqual(componentMove(kIds, 3, 9), null); + }); +}); diff --git a/packages/editors/terrain/src/features/overlays/toolbar/OverlayToolbar.ts b/packages/editors/terrain/src/features/overlays/toolbar/OverlayToolbar.ts index 92c52b786..997d90732 100644 --- a/packages/editors/terrain/src/features/overlays/toolbar/OverlayToolbar.ts +++ b/packages/editors/terrain/src/features/overlays/toolbar/OverlayToolbar.ts @@ -77,7 +77,7 @@ export class OverlayToolbar extends LitElement { const { overlay } = settings.options; return html` - +
${TERRAIN_OVERLAYS.map((option) => html` > = { @customElement("terrain-sculpt-toolbar") export class SculptToolbar extends LitElement { - static override styles = [ - viewportToolbarStyles, - css` - .step-count { - position: absolute; - inset-block-end: 1px; - inset-inline-end: 2px; - font-size: 9px; - font-weight: 600; - pointer-events: none; - } - ` - ]; + static override styles = viewportToolbarStyles; @property({ attribute: false }) declare settings: SculptSettings | null; @@ -134,7 +121,7 @@ export class SculptToolbar extends LitElement { const { tool, falloff } = settings.options; return html` - + ${this.#renderHistory()}
${SCULPT_TOOLS.map((option) => html` @@ -259,27 +246,23 @@ export class SculptToolbar extends LitElement { data-tool="undo" icon="undo" label="Undo (Ctrl+Z)" + .count=${state.undoCount} ?disabled=${state.undoCount === 0} @click=${() => history.undo(TERRAIN_HISTORY_SCOPE)} - >${renderStepCount(state.undoCount)} + > history.redo(TERRAIN_HISTORY_SCOPE)} - >${renderStepCount(state.redoCount)} + >
`; } } -function renderStepCount( - count: number -): TemplateResult | typeof nothing { - return count === 0 ? nothing : html``; -} - function formatMetres( value: number ): string { diff --git a/packages/editors/terrain/src/shared/toolbar.styles.ts b/packages/editors/terrain/src/shared/toolbar.styles.ts index fb94a9e87..25e868d90 100644 --- a/packages/editors/terrain/src/shared/toolbar.styles.ts +++ b/packages/editors/terrain/src/shared/toolbar.styles.ts @@ -6,15 +6,6 @@ export const viewportToolbarStyles = css` display: inline-flex; } - jolly-rail { - gap: 0; - height: auto; - padding: 4px; - border-radius: var(--jolly-radius-sm, 2px); - background: var(--jolly-surface-raised); - box-shadow: var(--jolly-shadow-floating); - } - .group { display: flex; align-items: center; diff --git a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts index cf673aae7..8e7b58260 100644 --- a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts +++ b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.styles.ts @@ -24,15 +24,8 @@ export const editToolbarStyles = [viewportToolbarStyles, css` jolly-rail { --jolly-icon-button-size: var(--voxel-toolbar-button-size); - flex-wrap: wrap; - justify-content: center; - align-items: center; gap: var(--voxel-toolbar-row-gap) 0; - height: auto; padding: var(--voxel-toolbar-row-gap) 4px; - border-radius: var(--jolly-radius-sm, 2px); - background: var(--jolly-surface-raised); - box-shadow: var(--jolly-shadow-floating); } .segment { @@ -84,31 +77,6 @@ export const editToolbarStyles = [viewportToolbarStyles, css` transition: translate 0s var(--voxel-segment-fade-out); } - .step-count { - position: absolute; - right: 0; - bottom: 0; - display: inline-flex; - align-items: center; - justify-content: center; - box-sizing: border-box; - min-width: 14px; - height: 14px; - padding: 0 3px; - border-radius: 7px; - background: var(--jolly-tone-teal); - color: var(--jolly-surface-raised); - font-size: 9px; - font-weight: 700; - font-variant-numeric: tabular-nums; - line-height: 1; - pointer-events: none; - } - - .step-count > span { - text-box: trim-both cap alphabetic; - } - @media (prefers-reduced-motion: reduce) { .segment, .segment[inert], diff --git a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts index 0d0db0160..b92b72320 100644 --- a/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts +++ b/packages/editors/voxel-map/src/shell/toolbar/EditToolbar.ts @@ -131,6 +131,7 @@ export class EditToolbar extends EditingElement { @@ -175,16 +176,18 @@ export class EditToolbar extends EditingElement { data-tool="undo" icon="undo" label=${shortcuts.label("Undo", "edit.undo")} + .count=${this._undoDepth} ?disabled=${access.current.readOnly || this._undoDepth === 0} @click=${this.#onUndo} - >${renderStepCount(this._undoDepth)}
+ > ${renderStepCount(this._redoDepth)} + >