UI styling, main workspace layout (#119)

This commit is contained in:
Danny McGee
2024-04-05 20:15:09 -04:00
committed by GitHub
parent c5a0250d9f
commit bfc638b644
57 changed files with 2004 additions and 694 deletions
@@ -23,7 +23,6 @@
.timeline-wrapper {
grid-area: timeline;
display: grid;
border-radius: 8px;
background: var(--color-panel-background);
grid-template-columns:
[sidebar-start] min-content [sidebar-end
@@ -146,7 +145,7 @@
grid-template-columns: subgrid;
grid-row: 2 / span var(--track-count);
.track {
&:hover {
background: #0000001F;
@@ -162,4 +161,4 @@
}
}
}
}
}
+54 -50
View File
@@ -1,7 +1,7 @@
import * as studio from "@storyteller/studio";
import type { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe } from "@storyteller/studio";
import type { EntitySelectEvent, SeekTimeChanged, UpdateAnimationTargets, Keyframe, AnimationTrack } from "@storyteller/studio";
import { bind, drag, inject, on, provide } from "@storyteller/framework";
import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult, PropertyValueMap } from "lit";
import { LitElement, type PropertyValues, html, unsafeCSS, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { styleMap } from "lit/directives/style-map.js";
@@ -27,6 +27,8 @@ function isOutsideEntity(
return "outsideEntity" in value;
}
// TODO: Consider refactoring to get/set most of the `@state` here through
// `AnimTimelineController`
@customElement("sts-anim-editor")
@provide(TREE_SELECTION_PROVIDER)
export class AnimEditorElement extends LitElement
@@ -47,7 +49,7 @@ export class AnimEditorElement extends LitElement
@state() playHead = 0;
@state() playing = false;
@state() tracks: Record<string, { name?: string, target_type: studio.SceneElement, animation_target: studio.AnimationTarget }> = {};
@state() tracks: Record<string, AnimationTrack> = {};
@state() track_ordered: [string, studio.AnimationTarget][] = [];
@@ -115,7 +117,7 @@ export class AnimEditorElement extends LitElement
delete tracks[track_entity];
}
this.track_ordered = Object.keys(tracks).map(entity => [entity, tracks[entity].animation_target]);
this.track_ordered = Object.keys(tracks).map(entity => [entity, tracks[entity].animationTarget]);
if (typeof this.selectedTrack === "object") {
if (!isOutsideEntity(this.selectedTrack) && !(this.selectedTrack.track in tracks)) {
@@ -309,44 +311,45 @@ export class AnimEditorElement extends LitElement
let timeline_css_variables = {
'--track-count': `${this.trackCount()}`,
'--row-count': `calc(var(--track-count) + ${canAddSelected ? 3 : 2})`,
'--row-count': `calc(var(--track-count) + ${/*canAddSelected ? 3 :*/ 2})`,
'--clip-end': `${clip_end * 100}%`,
'--clip-start': `${clip_start * 100}%`
};
const tracklist = this.track_ordered.map(([entity,]) => {
let track = this.tracks[entity];
let name = track.name ?? track.target_type;
let name = track.name ?? track.targetType;
let icon = this.sceneData?.entityMap.get(entity)?.icon ?? "minus";
return html`
<sts-tree class="track"
role="listitem"
name=${name}
icon=${icon}
@tree-select=${() => {
studio.select(entity);
}}
.id=${entity}
></sts-tree>
<sts-tree class="track"
role="listitem"
name=${name}
icon=${icon}
@tree-select=${() => {
studio.select(entity);
}}
.id=${entity}
></sts-tree>
`;
});
if (canAddSelected) {
tracklist.push(html`
<sts-tree class=${`track`}
role="button"
.name=${`Add Track: ${this.sceneData?.selectedObject?.name ?? canAddSelected}`}
icon="plus-large"
@click=${this.addTarget}></sts-tree>
`);
}
// if (canAddSelected) {
// tracklist.push(html`
// <sts-tree class=${`track`}
// role="button"
// .name=${`Add Track: ${this.sceneData?.selectedObject?.name ?? canAddSelected}`}
// icon="plus-large"
// @click=${this.addTarget}></sts-tree>
// `);
// }
const none_selected = !selected;
return html`
<!--
<div class="timeline-controls">
<sts-toolbar>
<sts-toolbar-group>
<sts-toolbar-group>
${this.keyingMode === "automatic" ? html`
<sts-button class="btn-autokey active"
icon="record"
@@ -404,35 +407,36 @@ export class AnimEditorElement extends LitElement
</sts-toolbar-group>
</sts-toolbar>
</div>
-->
<div class="timeline-wrapper">
<div class="sidebar"
style=${styleMap({
width: `${this.sidebarWidth}px`,
...timeline_css_variables
})}
>
<div class="tracklist" role="list">
${tracklist}
<div class="sidebar"
style=${styleMap({
width: `${this.sidebarWidth}px`,
...timeline_css_variables
})}
>
<div class="tracklist" role="list">
${tracklist}
</div>
</div>
</div>
<div class="divider" style=${styleMap(timeline_css_variables)}
${drag({
move: this.onSidebarResize,
})}
></div>
<div class="divider" style=${styleMap(timeline_css_variables)}
${drag({
move: this.onSidebarResize,
})}
></div>
<sts-anim-timeline class="timeline"
style=${styleMap(timeline_css_variables)}
.rangeStart=${bind(this, "timelineStart")}
.rangeEnd=${bind(this, "timelineEnd")}
.clipStart=${this.clipStart}
.clipEnd=${this.clipEnd}
.playHead=${this.playHead}
.keyframes=${bind(this, "keyframes")}
.track_ordered=${track_order_hashed}
.track_animation_target_ordered=${track_animation_target_order_hashed}
></sts-anim-timeline>
<sts-anim-timeline class="timeline"
style=${styleMap(timeline_css_variables)}
.rangeStart=${bind(this, "timelineStart")}
.rangeEnd=${bind(this, "timelineEnd")}
.clipStart=${this.clipStart}
.clipEnd=${this.clipEnd}
.playHead=${this.playHead}
.keyframes=${bind(this, "keyframes")}
.track_ordered=${track_order_hashed}
.track_animation_target_ordered=${track_animation_target_order_hashed}
></sts-anim-timeline>
</div>
`
};
@@ -0,0 +1,3 @@
:host {
display: contents;
}
@@ -0,0 +1,228 @@
import { DynamicProvider, UniqueToken, inject, on, provide } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
type AnimationTarget,
type AnimationTrack,
type Keyframe,
type SeekTimeChanged,
type UpdateAnimationTargets,
type UpdateKeyframes,
} from "@storyteller/studio";
import {
SCENE_DATA_PROVIDER,
type SceneDataProvider,
} from "@storyteller/studio-web/scene-data-provider";
import { LitElement, PropertyValues, html, unsafeCSS } from "lit";
import { customElement, state } from "lit/decorators.js";
import styles from "./anim-timeline-controller.element.scss?inline";
export const ANIM_TIMELINE_CONTROLLER = UniqueToken.create<AnimTimelineController>();
export interface AnimTimelineController extends DynamicProvider<AnimTimelineController> {
readonly seekTime: number;
readonly playing: boolean;
readonly tracks: Record<Entity, AnimationTrack>;
readonly tracksOrdered: [Entity, AnimationTarget][];
readonly keyframes: Record<Entity, Keyframe>;
readonly clipStart: number;
readonly clipEnd: number;
setSeekTime(time: number): void;
addTrack(entity: Entity): void;
removeTrack(entity: Entity): void;
addKeyframe(): void;
togglePlaying(): void;
play(): void;
pause(): void;
jumpToStart(): void;
jumpToEnd(): void;
}
export type Entity = string;
@customElement("sts-anim-timeline-controller")
@provide(ANIM_TIMELINE_CONTROLLER)
export class AnimTimelineControllerElement
extends LitElement
implements AnimTimelineController
{
static override styles = unsafeCSS(styles);
@state() seekTime = 0;
@state() playing = false;
@state() tracks: Record<Entity, AnimationTrack> = {};
@state() tracksOrdered: [Entity, AnimationTarget][] = [];
@state() selectedTrack: AnimationTrack | null = null;
@state() keyframes: Record<Entity, Keyframe> = {};
@state() clipStart = 0;
@state() clipEnd = 0;
@inject(SCENE_DATA_PROVIDER)
_sceneData!: SceneDataProvider;
setSeekTime(time: number): void {
studio.seekTime(time);
}
addTrack(entity: Entity): void {
if (!(entity in this.tracks))
studio.addEntityTrack(entity);
}
removeTrack(entity: Entity): void {
this.tracks = Object.fromEntries(
Object.entries(this.tracks).filter(([key]) => key !== entity)
)
}
addKeyframe(): void {
const selected = this._sceneData.selectedEntity;
if (selected != null && selected in this.tracks) {
studio.recordKeyframe([selected]);
}
}
togglePlaying(): void {
if (this.playing) studio.pause();
else studio.play();
}
play(): void {
studio.play();
}
pause(): void {
studio.pause();
}
jumpToStart(): void {
if (Math.abs(this.seekTime - this.clipStart) < 0.0001) {
studio.seekTime(0);
} else {
studio.seekTime(this.clipStart);
}
}
jumpToEnd(): void {
studio.seekTime(this.clipEnd);
}
override connectedCallback(): void {
this._sceneData?.addEventListener("property-changes", this._onSceneDataUpdated);
super.connectedCallback();
}
override disconnectedCallback(): void {
this._sceneData?.removeEventListener("property-changes", this._onSceneDataUpdated);
super.disconnectedCallback()
}
protected override willUpdate(changes: PropertyValues<this>): void {
if (changes.has("tracks")) {
this.tracksOrdered = Object
.entries(this.tracks)
.map(([entity, track]) => [entity, track.animationTarget]);
}
if (changes.has("keyframes")) {
let min = Infinity
let max = -Infinity;
const keyframeKeys = Object.keys(this.keyframes);
if (!keyframeKeys.length) {
this.clipStart = 0;
this.clipEnd = 0;
return;
}
for (const keyframe of keyframeKeys) {
const current = this.keyframes[keyframe];
let time = current.time;
if (typeof time != "number") {
continue;
}
if (time > max) {
max = time;
}
if (time < min) {
min = time;
}
if ("duration" in current) {
const endTime = time + current.duration;
if (endTime > max) {
max = endTime
}
}
}
this.clipStart = Math.max(min, 0.);
this.clipEnd = Math.max(max, min);
}
super.willUpdate(changes);
}
protected override update(changes: PropertyValues<this>): void {
this.dispatchEvent(new CustomEvent("property-changes", {
detail: new Map(changes.entries())
}));
super.update(changes);
}
_onSceneDataUpdated = ({ detail: changes }: CustomEvent<PropertyValues<SceneDataProvider>>) => {
if (changes.has("selectedEntity")) {
const selected = this._sceneData.selectedEntity;
if (selected) {
this.selectedTrack = this.tracks[selected] ?? null;
} else {
this.selectedTrack = null;
}
}
}
@on("window:seek-time-changed")
_onSeekTimeChanged({ detail: { time, playing }}: SeekTimeChanged): void {
this.seekTime = time;
this.playing = playing;
}
@on("window:update-animation-targets")
_onUpdateAnimationTargets({ detail: { added, removed }}: UpdateAnimationTargets): void {
if (Object.keys(added).length === 0 && removed.length === 0)
return;
const updated = {
...this.tracks,
...added,
};
for (let entity of removed)
delete updated[entity];
this.tracks = updated;
console.log("tracks:", this.tracks);
}
@on("window:update-keyframes")
_onUpdateKeyframes({ detail: { added, removed }}: UpdateKeyframes): void {
if (Object.keys(added).length === 0 && removed.length === 0)
return;
const updated = {
...this.keyframes,
...added,
};
for (let key of removed)
delete updated[key];
this.keyframes = updated;
}
protected override render = () => html`
<slot></slot>
`;
}
@@ -0,0 +1,28 @@
.play-pause {
align-self: center;
padding: 0.4rem;
border-radius: 50%;
background: #FFF;
color: var(--neutral-100);
&::part(icon) {
width: 1em;
font-size: 0.75rem;
}
}
.timecode {
display: block;
align-self: center;
margin-inline: 0.5rem;
color: rgba(#FFF, 0.5);
font: {
size: 0.875rem;
weight: 500;
variant-numeric: tabular-nums;
}
strong {
color: #FFF;
}
}
@@ -0,0 +1,63 @@
import { inject, observe } from "@storyteller/framework";
import { LitElement, html, unsafeCSS } from "lit";
import { customElement } from "lit/decorators.js";
import {
ANIM_TIMELINE_CONTROLLER,
type AnimTimelineController,
} from "./anim-timeline-controller.element";
import styles from "./anim-timeline-controls.element.scss?inline";
@customElement("sts-anim-timeline-controls")
export class AnimTimelineControlsElement extends LitElement {
static override styles = unsafeCSS(styles);
@observe(["seekTime", "clipEnd", "playing"])
@inject(ANIM_TIMELINE_CONTROLLER)
_timeline!: AnimTimelineController;
timestamp(fractionalSeconds: number): string {
function modf(num: number, divisor: number): [number, number] {
return [Math.floor(num / divisor), Math.floor(num % divisor)];
}
const totalMs = Math.round(fractionalSeconds * 1000);
const [totalSeconds, ms] = modf(totalMs, 1000);
const [minutes, seconds] = modf(totalSeconds, 60);
return `${
minutes.toString().padStart(2, "0")
}:${
seconds.toString().padStart(2, "0")
}.${
ms.toString().padStart(3, "0")
}`
}
protected override render = () => html`
<sts-toolbar>
<sts-toolbar-group>
<sts-button
icon="bwd-step"
@click=${() => this._timeline.jumpToStart()}
></sts-button>
<sts-button
class="play-pause"
.icon=${this._timeline.playing ? "pause" : "play"}
@click=${() => this._timeline.togglePlaying()}
></sts-button>
<sts-button
icon="fwd-step"
@click=${() => this._timeline.jumpToEnd()}
></sts-button>
<span class="timecode">
<strong>${this.timestamp(this._timeline.seekTime)}</strong>
/ ${this.timestamp(this._timeline.clipEnd)}
</span>
</sts-toolbar-group>
</sts-toolbar>
`;
}
@@ -64,7 +64,6 @@
}
user-select: none;
pointer-events: none;
z-index: 1;
.frac {
color: #FFF3;
+24 -118
View File
@@ -3,94 +3,32 @@
$hierarchy-width: 290px;
.app-root {
position: fixed;
inset: 0;
--sidebar-width: 300px;
--timeline-height: max-content;
}
--timeline-height: min-content;
.debug-mode {
.studio-scene {
z-index: 100;
}
}
.wrapper {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: grid;
pointer-events: none;
grid-template-columns: [timeline-start screen-start] 2rem [left-toolbar-start help-start] max-content [left-toolbar-end] auto [center-toolbar-start] max-content [center-toolbar-end] auto [right-toolbar-start] max-content [right-toolbar-end] 2rem [screen-end help-end sidebar-start] var(--sidebar-width) [sidebar-end timeline-end];
grid-template-rows: [top-toolbar-start sidebar-start screen-start] min-content [top-toolbar-end help-start sidebar-view-start] max-content [help-end] auto [bottom-toolbar-start] max-content [bottom-toolbar-end sidebar-end sidebar-view-end screen-end timeline-start] var(--timeline-height) [timeline-end];
.studio-scene {
position: fixed;
bottom: 0;
left: 0;
right: 0;
pointer-events: all;
width: 100vw;
height: 100vh;
grid-template: {
areas:
"viewport sidebar"
"timeline timeline";
rows: minmax(0, 1fr) var(--timeline-height);
columns: minmax(0, 1fr) var(--sidebar-width);
}
.action-toolbar {
grid-column: left-toolbar;
grid-row: top-toolbar;
padding-top: var(--padding);
sts-toolbar-group {
gap: var(--padding);
}
sts-button {
background-color: var(--color-toolbar-background);
}
z-index: 1;
}
.timeline {
grid-row-start: bottom-toolbar-start;
grid-row-end: timeline-end;
grid-column: timeline;
z-index: 1;
border-radius: 0;
}
.viewport { grid-area: viewport; }
.sidebar { grid-area: sidebar; }
.timeline { grid-area: timeline; }
.sidebar {
display: grid;
grid-area: sidebar;
grid-template-rows: subgrid;
z-index: 1;
display: flex;
flex-direction: column;
background-color: var(--color-panel-background);
border-radius: 0;
position: relative;
padding: var(--padding);
padding-left: 0.5rem;
.resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.5rem;
background: #0004;
transition: 66.666ms background linear;
&:hover {
background: #FFF1;
}
}
.sidebar-header {
grid-row: top-toolbar;
display: flex;
flex-direction: row;
justify-content: stretch;
gap: 0;
.sidebar-tab {
@@ -108,51 +46,19 @@ $hierarchy-width: 290px;
}
.sidebar-view {
grid-row: sidebar-view;
}
}
.help {
grid-area: help;
z-index: 1;
display: flex;
flex-direction: row;
justify-content: start;
}
.entity-inspector {
grid-column: left-toolbar;
grid-row: bottom-toolbar;
z-index: 1;
padding-bottom: var(--padding);
& > * {
pointer-events: all;
}
}
.transform-toolbar {
grid-column: center-toolbar;
grid-row: top-toolbar;
background-color: var(--color-toolbar-background);
z-index: 1;
border-top-right-radius: 0;
border-top-left-radius: 0;
border-bottom-right-radius: var(--corner-radius);
border-bottom-left-radius: var(--corner-radius);
padding: var(--padding);
display: flex;
flex-direction: row;
gap: 1rem;
.add-to-scene-dropdown {
background-color: var(--color-toolbar-background-light);
font-size: 16px;
padding: 10px 12px;
padding: 1rem;
}
}
}
.studio-scene.block-pointer-events::part(canvas) {
.help {
grid-area: help;
display: flex;
flex-direction: row;
justify-content: start;
}
.studio.block-pointer-events::part(canvas) {
pointer-events: none;
}
@@ -215,4 +121,4 @@ $hierarchy-width: 290px;
.menu-button-icon {
font-size: 12px;
opacity: 0.75;
}
}
+73 -132
View File
@@ -1,14 +1,14 @@
import { drag, on, provide } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import {
type SceneSavedEvent,
type SceneStateEvent,
SceneState,
StudioMode,
type DebugModeToggled,
} from "@storyteller/studio";
import { ButtonElement } from "@storyteller/studio-ui/button";
import { DialogElement } from "@storyteller/studio-ui/dialog";
import {
STUDIO_PARAMS,
SceneHierarchyElement,
StorytellerApi,
StorytellerLocalApi,
@@ -18,10 +18,9 @@ import {
} from "@storyteller/studio-web";
import { match } from "@storyteller/utility";
import { LitElement, TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import { createRef, ref } from "lit/directives/ref.js";
import { styleMap } from "lit/directives/style-map.js";
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/dialog";
@@ -39,7 +38,9 @@ import "@storyteller/studio-web/scene-hierarchy";
import "@storyteller/studio-web/studio";
import "@storyteller/studio-web/transform-toolbar";
import "./anim-editor.element";
import "./anim-timeline-controller.element";
import "./help.element";
import "./studio-viewport.element";
import "./temp-video-preview.element";
import "./app.element.scss";
@@ -49,12 +50,16 @@ import "./app.element.scss";
token: StorytellerApi,
provide() { return this.api; }
})
@provide({
token: STUDIO_PARAMS,
provide() { return this.params; }
})
export class AppElement extends LitElement {
@state() showHelp = false;
@state() loading = true;
@state() resizingPanel = false;
@state() inspectorWidth = 320;
// @state() resizingPanel = false;
// @state() inspectorWidth = 320;
@state() debugMode = false;
@@ -62,16 +67,13 @@ export class AppElement extends LitElement {
@state() currentSidebar : string = 'hierarchy';
@property({ attribute: false }) api: StorytellerApi = new StorytellerApi(mockApi ? new StorytellerLocalApi() : new StorytellerRemoteApi());
api = new StorytellerApi(mockApi ? new StorytellerLocalApi() : new StorytellerRemoteApi());
params = new StudioURLParams();
#addToSceneButtonRef = createRef<ButtonElement>();
#videoStylePreviewBtnRef = createRef<ButtonElement>();
#mediaLibraryRef = createRef<DialogElement>();
#inspectorRef = createRef<HTMLDivElement>();
#hierarchyRef = createRef<SceneHierarchyElement>();
#params = new StudioURLParams();
protected override createRenderRoot(): Element | ShadowRoot {
return this;
}
@@ -80,7 +82,7 @@ export class AppElement extends LitElement {
async onSceneSaved({ detail: scene }: SceneSavedEvent): Promise<void> {
try {
const file = new File([scene], "scene.scn.ron", { type: "text/ron" });
const token = await this.api.createScene(file);
this.dispatchEvent(new CustomEvent("scene-uploaded", {
@@ -88,7 +90,7 @@ export class AppElement extends LitElement {
bubbles: true,
composed: true,
}));
}
catch (err) {
console.error(err);
@@ -106,22 +108,18 @@ export class AppElement extends LitElement {
}
@on("window:debug-mode-toggled")
onDebugModeToggled({detail: { debugModeActive }}: studio.DebugModeToggled ): void {
onDebugModeToggled({detail: { debugModeActive }}: DebugModeToggled ): void {
this.debugMode = debugModeActive;
}
onPanelResizeStart(): void {
this.resizingPanel = true;
}
// onPanelResizeEnd(): void {
// this.resizingPanel = false;
// }
onPanelResizeEnd(): void {
this.resizingPanel = false;
}
resizeInspectorWidth(event: PointerEvent): void {
const updated = this.inspectorWidth - event.movementX;
this.inspectorWidth = Math.max(Math.min(updated, 1024), 128);
}
// resizeInspectorWidth(event: PointerEvent): void {
// const updated = this.inspectorWidth - event.movementX;
// this.inspectorWidth = Math.max(Math.min(updated, 1024), 128);
// }
toggleHelp(): void {
this.showHelp = !this.showHelp;
@@ -140,134 +138,77 @@ export class AppElement extends LitElement {
protected override render() {
return html`
<sts-canvas-provider class=${classMap({"app-root": true, "debug-mode": this.debugMode})}>
<sts-canvas-provider>
<sts-scene-data-provider>
<sts-overlay-provider>
<sts-anim-timeline-controller>
<sts-overlay-provider
class=${classMap({
"app-root": true,
"debug-mode": this.debugMode,
})}
>
<sts-iframe-messaging-bridge></sts-iframe-messaging-bridge>
${this.#params.mode === StudioMode.Editor ? html`
<sts-add-to-scene-menu
.trigger=${this.#addToSceneButtonRef}
@open-media-library=${() => {
this.#mediaLibraryRef.value?.open();
}}
></sts-add-to-scene-menu>
<sts-dialog
${ref(this.#mediaLibraryRef)}
tag="sts-media-library-dialog"
nonBlocking
></sts-dialog>
` : html`<></>`}
<div class="wrapper" style=${styleMap({ "--sidebar-width": `${this.inspectorWidth}px` })}>
<!-- Scene view -->
<sts-studio
class=${classMap({
"studio-scene": true,
"block-pointer-events": this.resizingPanel,
})}
.objectId=${this.#params.objectId}
.bvhPath=${this.#params.bvh}
.mixamoPath=${this.#params.mixamo}
.sceneImport=${this.#params.sceneImport}
.storytellerScene=${this.#params.scene}
.skyboxId=${this.#params.skybox ?? "test_scene"}
.mode=${this.#params.mode}
></sts-studio>
<!-- Actions toolbar -->
<sts-toolbar class="action-toolbar">
<sts-toolbar-group>
${this.#params.mode === StudioMode.Editor ? html`
<sts-button
@click=${() => studio.saveScene()}
>
Save Scene
</sts-button>
` : nothing}
<sts-button
class=${classMap({ active: this.showHelp })}
@click=${this.toggleHelp}
>
Help
</sts-button>
</sts-toolbar-group>
</sts-toolbar>
<sts-studio-viewport class="viewport"></sts-studio-viewport>
<!-- Studio-mode-only elements -->
${this.#params.mode === StudioMode.Editor ? html`
<div class="transform-toolbar">
<sts-button
${ref(this.#addToSceneButtonRef)}
icon="plus-large"
class="add-to-scene-dropdown"
>
</sts-button>
<sts-button
${ref(this.#videoStylePreviewBtnRef)}
icon="films"
class="video-style-preview-dropdown"
>
</sts-button>
<sts-transform-toolbar></sts-transform-toolbar>
</div>
<section
${ref(this.#inspectorRef)}
class="sidebar"
style=${styleMap({
width: `${this.inspectorWidth}px`,
})}
aria-labelledby="sidebar-heading"
>
<div class="sidebar-header">
${this.sidebars.map(({title, name}) => {
return html`<sts-button class=${classMap({"current-tab": name === this.currentSidebar, "sidebar-tab": true})}>${title}</sts-button>`
})}
</div>
${this.renderSidebar()}
<div
class="resize-handle"
${drag({
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeInspectorWidth,
})}
></div>
</section>
<div class="entity-inspector">
<sts-entity-inspector></sts-entity-inspector>
${this.params.mode === StudioMode.Editor ? html`
<section
${ref(this.#inspectorRef)}
class="sidebar"
aria-labelledby="sidebar-heading"
>
<div class="sidebar-header">
${this.sidebars.map(({title, name}) => html`
<sts-button
class=${classMap({
"current-tab": name === this.currentSidebar,
"sidebar-tab": true
})}
>
${title}
</sts-button>
`)}
</div>
<sts-anim-editor
class="timeline"
style=${styleMap({
right: `${this.inspectorWidth + 32}px`
})}
></sts-anim-editor>
${this.renderSidebar()}
<!-- <div
class="resize-handle"
${/*drag({
start: this.onPanelResizeStart,
end: this.onPanelResizeEnd,
move: this.resizeInspectorWidth,
})*/ nothing}
></div> -->
</section>
<!-- TODO
<div class="entity-inspector">
<sts-entity-inspector></sts-entity-inspector>
</div>
-->
<sts-anim-editor class="timeline"></sts-anim-editor>
` : nothing}
<!-- Dialogs and modals -->
<sts-temp-video-preview
.trigger=${ this.#videoStylePreviewBtnRef }>
</sts-temp-video-preview>
<!-- Dialogs and modals -->
${this.showHelp ? html`
<div class="help">
<sts-help></sts-help>
<div class="help">
<sts-help></sts-help>
</div>
` : nothing}
</div>
${this.loading ? html`
<sts-loading></sts-loading>
` : nothing}
</sts-overlay-provider>
</sts-anim-timeline-controller>
</sts-scene-data-provider>
</sts-canvas-provider>
`;
@@ -0,0 +1,152 @@
@use "sass:math";
sts-studio-viewport {
background: var(--neutral-100);
.studio {
position: relative;
.debug-mode & :not([slot="canvas"]) {
display: none;
}
}
.stylize {
gap: 0.5rem;
padding: 0.5rem 0.75rem;
line-height: 1;
sts-icon {
align-self: center;
}
}
.timeline-controls {
background: var(--neutral-200);
border-radius: 0.5rem;
}
sts-toolbar,
.timeline-controls {
&.v-start {
border: {
top-left-radius: 0;
top-right-radius: 0;
}
}
&.v-end {
border: {
bottom-left-radius: 0;
bottom-right-radius: 0;
}
}
}
.abs {
position: absolute;
}
.h-start {
left: 0;
&.offset {
left: 1rem;
}
}
.h-end {
right: 0;
&.offset {
right: 1rem;
}
}
.v-start {
top: 0;
&.offset {
top: 1rem;
}
}
.v-end {
bottom: 0;
&.offset {
bottom: 1rem;
}
}
.h-center {
left: 50%;
transform: translateX(-50%);
}
.primitive::part(icon) {
font-size: 1.125rem;
}
.transform-space {
margin-inline-end: 0.75rem;
}
.inspector {
min-width: math.div(330, 16) * 1rem;
padding: 1rem;
border: 1px solid var(--neutral-300);
border-radius: 0.5rem;
background: var(--neutral-100);
&__header {
display: flex;
align-items: center;
gap: 1.5rem;
margin: 0 0 1.25rem;
color: #FFF;
.object-name {
display: flex;
align-items: center;
gap: 0.5rem;
h3 {
margin: 0;
font: {
size: 1rem;
weight: 700;
}
}
}
.swap-object {
padding: 0;
gap: 0.375rem;
margin-inline-start: auto;
background: transparent;
color: rgba(#FFF, 0.6);
font-size: 0.75rem;
&:hover {
color: #FFF;
}
}
}
&__footer {
display: flex;
gap: 0.5rem;
margin: 1.75rem 0 0;
.add-keyframe,
.add-to-timeline {
flex: 1 0 0;
}
.add-to-timeline {
justify-content: center;
}
.delete {
padding-inline: 0.8rem;
}
}
}
}
@@ -0,0 +1,288 @@
import { bind, inject, observe } from "@storyteller/framework";
import * as studio from "@storyteller/studio";
import { BlockingCommand, TransformSpace, TransformType } from "@storyteller/studio";
import {
SCENE_DATA_PROVIDER,
STUDIO_PARAMS,
type SceneDataProvider,
type StudioParams,
} from "@storyteller/studio-web";
import { ButtonElement } from "@storyteller/studio-ui/button";
import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import {
ANIM_TIMELINE_CONTROLLER,
type AnimTimelineController,
} from "./anim-timeline-controller.element";
import "@storyteller/studio-web/entity-inspector";
import "@storyteller/studio-web/studio";
import "@storyteller/studio-ui/button";
import "@storyteller/studio-ui/forms/radio";
import "@storyteller/studio-ui/forms/select";
import "@storyteller/studio-ui/toolbar";
import "./anim-timeline-controls.element";
import "./studio-viewport.element.scss";
@customElement("sts-studio-viewport")
export class StudioViewportElement extends LitElement {
@inject(STUDIO_PARAMS)
_params!: StudioParams;
@observe(["selectedEntity", "selectedObject"])
@inject(SCENE_DATA_PROVIDER)
_sceneData!: SceneDataProvider;
@observe(["tracks"])
@inject(ANIM_TIMELINE_CONTROLLER)
_timeline!: AnimTimelineController;
@state() _transformSpace = TransformSpace.Local;
@state() _transformType = TransformType.Translate;
get #selectedEntityInTracklist() {
return (
this._sceneData.selectedEntity != null
&& this._sceneData.selectedEntity in this._timeline.tracks
);
}
#fileTriggerRef = createRef<ButtonElement>();
#mediaLibraryTriggerRef = createRef<ButtonElement>();
get #selectedObject() {
return this._sceneData.selectedObject;
}
protected override createRenderRoot = () => this;
protected override update(changes: PropertyValues<this>): void {
if (changes.has("_transformSpace"))
studio.setTransformSpace(this._transformSpace);
if (changes.has("_transformType"))
studio.setTransformType(this._transformType);
super.update(changes);
}
protected override render(): TemplateResult {
return html`
<sts-studio
class="studio"
.objectId=${this._params.objectId}
.bvhPath=${this._params.bvh}
.mixamoPath=${this._params.mixamo}
.sceneImport=${this._params.sceneImport}
.storytellerScene=${this._params.scene}
.skyboxId=${this._params.skybox ?? "test_scene"}
.mode=${this._params.mode}
>
<div class="main-menu abs h-start v-start offset">
<sts-button
${ref(this.#fileTriggerRef)}
dropdown
icon="file"
>
File
</sts-button>
<sts-button
icon="question"
@click=${() => {
// TODO
}}
>
Help
</sts-button>
</div>
<sts-toolbar class="top-toolbar abs h-center v-start">
<sts-toolbar-group>
<sts-button
${ref(this.#mediaLibraryTriggerRef)}
secondary
icon="plus-large"
title="Add asset from Media Library"
></sts-button>
</sts-toolbar-group>
<hr />
<sts-toolbar-group>
<sts-button
class="primitive"
icon="cube"
title="Cube"
@click=${() => studio.dispatchBlockingCommand(BlockingCommand.Cube)}
></sts-button>
<sts-button
class="primitive"
icon="cylinder"
title="Cylinder"
@click=${() => studio.dispatchBlockingCommand(BlockingCommand.Cylinder)}
></sts-button>
<sts-button
class="primitive"
icon="sphere"
title="Sphere"
@click=${() => studio.dispatchBlockingCommand(BlockingCommand.Sphere)}
></sts-button>
<sts-button
class="primitive"
icon="torus"
title="Torus"
@click=${() => studio.dispatchBlockingCommand(BlockingCommand.Torus)}
></sts-button>
</sts-toolbar-group>
<hr />
<sts-toolbar-group>
<sts-select class="transform-space"
title="Transform space"
.value=${bind(this, "_transformSpace")}
>
<sts-option .value=${TransformSpace.Local}>
<sts-icon icon="xform-local"></sts-icon>
Local
</sts-option>
<sts-option .value=${TransformSpace.World}>
<sts-icon icon="globe"></sts-icon>
Global
</sts-option>
</sts-select>
<sts-radio-group
title="Transform type"
.value=${bind(this, "_transformType")}
>
<sts-radio .value=${TransformType.Translate}>
<sts-icon icon="up-down-left-right">Translate</sts-icon>
</sts-radio>
<sts-radio .value=${TransformType.Rotate}>
<sts-icon icon="rotate">Rotate</sts-icon>
</sts-radio>
<sts-radio .value=${TransformType.Scale}>
<sts-icon icon="scale">Scale</sts-icon>
</sts-radio>
</sts-radio-group>
</sts-toolbar-group>
<hr />
<sts-toolbar-group>
<sts-button class="stylize" primary>
Stylize <sts-icon icon="chevron-right"></sts-icon>
</sts-button>
</sts-toolbar-group>
</sts-toolbar>
<div class="abs h-end v-start offset">
<sts-button>Switch to Camera View</sts-button>
</div>
<sts-anim-timeline-controls
class="timeline-controls abs h-center v-end"
></sts-anim-timeline-controls>
${this.#selectedObject != null ? html`
<div class="inspector abs h-end v-end offset">
<header class="inspector__header">
<div class="object-name">
${this.#selectedObject.icon != null ? html`
<sts-icon .icon=${this.#selectedObject.icon}></sts-icon>
` : nothing}
<h3>${this.#selectedObject.name}</h3>
</div>
<sts-button
class="swap-object"
icon="swap"
>
Swap object
</sts-button>
</header>
<sts-entity-inspector></sts-entity-inspector>
<footer class="inspector__footer">
${this.#selectedEntityInTracklist ? html`
<sts-button
class="add-keyframe"
keybind="K"
@click=${() => this._timeline.addKeyframe()}
>
Add Keyframe
</sts-button>
` : this._sceneData.selectedEntity != null ? html`
<sts-button
class="add-to-timeline"
@click=${() => this._timeline.addTrack(this._sceneData.selectedEntity!)}
>
Add to Timeline
</sts-button>
` : nothing}
<sts-button
class="delete"
icon="trash"
title="Delete Entity"
></sts-button>
</footer>
</div>
` : nothing}
</sts-studio>
<sts-menu
autoClose
.trigger=${this.#fileTriggerRef}
>
<sts-button
role="menuitem"
keybind="Ctrl+N"
@click=${() => {
// TODO
}}
>
New scene
</sts-button>
<sts-button
role="menuitem"
keybind="Ctrl+Shift+N"
@click=${() => {
// TODO
}}
>
New scene from template...
</sts-button>
<hr />
<sts-button
role="menuitem"
keybind="Ctrl+S"
@click=${() => {
// TODO: Should we show a spinner here while we're waiting?
// Toast message for success/failure?
// TODO: Need to disable this when the scene is not ours
studio.saveScene();
}}
>
Save scene
</sts-button>
<sts-button
role="menuitem"
keybind="Ctrl+Shift+S"
@click=${() => {
// TODO
}}
>
Save scene as copy...
</sts-button>
</sts-menu>
<sts-dialog
.trigger=${this.#mediaLibraryTriggerRef}
tag="sts-media-library-dialog"
nonBlocking
></sts-dialog>
`;
}
}
+20 -1
View File
@@ -10,14 +10,33 @@ html, body {
--color-panel-background: #1e1e2c;
--color-toolbar-background: #2c2c3b;
--color-toolbar-background-light: #595970;
--color-toolbar-background-light: #595970;
--color-panel-item-name-background: #39394d;
--color-panel-item-background: #55556b;
--color-accent: #fc6b68;
--color-accent-hover: #FF8D8B;
--color-accent-fade: #fc6b6840;
--color-text: #dbdbdf;
--color-text-bright: #ffffff;
--color-text-disabled: #7c7c85;
--axis-red: rgb(220, 53, 69);
--axis-green: rgb(25, 135, 84);
--axis-blue: rgb(13, 110, 253);
--corner-radius: 0.75rem;
--padding: 0.4rem;
--neutral-100: #242433;
--neutral-200: #39394D;
--neutral-250: #44445C;
--neutral-300: #515168;
--neutral-350: #505068;
--neutral-400: #676781;
--neutral-450: #6D6D80;
}
body {
color-scheme: dark;
font-family: "Fira Sans", sans-serif;
}