Skip to content

Tech Design for In-editor Routing #1799

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/develop/in-editor-router/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
export interface Router {
currentRoute: any
push(path: string): void
}
export declare class Runtime {}
export declare class Project {
stage: Stage
sprites: Sprite[]
sounds: Sound[]
}
export declare class Stage {
widgets: Widget[]
backdrops: Backdrop[]
}
export declare class Sprite {
id: string
name: string
costumes: Costume[]
animations: Animation[]
}
export declare class Sound {
id: string
name: string
}
export declare class Backdrop {
id: string
name: string
}
export declare class Widget {
id: string
name: string
}
export declare class Animation {
id: string
name: string
}
export declare class Costume {
id: string
name: string
}
export type ResourceModel = Stage | Sound | Sprite | Backdrop | Widget | Animation | Costume

export type UI = any
export type WatchSource<T> = (() => T) | {
value: T
}
export declare function toValue<T>(source: WatchSource<T>): T
export declare function watch<T>(source: WatchSource<T>, callback: (value: T) => void, options?: { immediate?: boolean }): void

export function shiftSegment(path: string): [segment: string, extra: string] {
const idx = path.indexOf('/')
if (idx === -1) return [path, '']
return [path.slice(0, idx), path.slice(idx)]
}

export type ResourceURI = string

export type ResourceIdentifier = {
uri: ResourceURI
}
28 changes: 28 additions & 0 deletions docs/develop/in-editor-router/impl_CodeEditorUI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ResourceIdentifier, ResourceModel } from './base'
import { EditorState } from './module_EditorState'

declare const editorCtx: {
state: EditorState
}

declare const builtInCommandGoToResource: any

declare function getResourceModel(resourceId: ResourceIdentifier): ResourceModel

class CodeEditorUI {

registerCommand(command: any, options: any) {
// Implementation for registering a command
}

init() {
this.registerCommand(builtInCommandGoToResource, {
icon: 'goto',
title: { en: 'View detail', zh: '查看详情' },
handler: async (resourceId: ResourceIdentifier) => {
const resource = getResourceModel(resourceId)
editorCtx.state.selectByResource(resource)
}
})
}
}
122 changes: 122 additions & 0 deletions docs/develop/in-editor-router/impl_EditorState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Project, Router, Runtime, Sound, Sprite, shiftSegment, watch } from './base'
import { SpriteState } from './module_SpriteEditor'
import { StageState } from './module_StageEditor'

type Selected = {
type: 'stage'
state: StageState
} | {
type: 'sprite'
sprite: Sprite | null
state: SpriteState | null
} | {
type: 'sound'
sound: Sound | null
}

class EditorState {

private selectedType: 'stage' | 'sprite' | 'sound' = 'sprite'
private selectedSpriteId: string | null = null
private selectedSoundId: string | null = null
private stageState = new StageState(this.project.stage)
private spriteState: SpriteState | null = null

get selectedSprite() {
return this.project.sprites.find(s => s.id === this.selectedSpriteId) ?? null
}

get selectedSound() {
return this.project.sounds.find(s => s.id === this.selectedSoundId) ?? null
}

get selected(): Selected {
switch (this.selectedType) {
case 'stage':
return { type: 'stage', state: this.stageState }
case 'sound':
return { type: 'sound', sound: this.selectedSound }
default:
const sprite = this.selectedSprite
if (sprite == null) return { type: 'sprite', sprite: null, state: null }
return {
type: 'sprite',
sprite,
state: this.spriteState
}
}
}

select(target: { type: 'stage' } | { type: 'sprite'; id: string } | { type: 'sound'; id: string }): void {
switch (target.type) {
case 'stage':
this.selectedType = 'stage'
break
case 'sprite':
this.selectedType = 'sprite'
this.selectedSpriteId = target.id
this.spriteState = new SpriteState(this.selectedSprite!)
break
case 'sound':
this.selectedType = 'sound'
this.selectedSoundId = target.id
break
}
}

selectByName(target: { type: 'stage' } | { type: 'sprite'; name: string } | { type: 'sound'; name: string }): void {
switch (target.type) {
case 'stage':
this.select(target)
break
case 'sprite':
const spriteId = this.project.sprites.find(s => s.name === target.name)?.id
this.select({ type: 'sprite', id: spriteId! })
break
case 'sound':
const soundId = this.project.sounds.find(s => s.name === target.name)?.id
this.select({ type: 'sound', id: soundId! })
break
}
}

private selectByRoute(path: string) {
const [segment, extra] = shiftSegment(path)
switch (segment) {
case 'stage':
this.select({ type: 'stage' })
return
case 'sprites':
const [spriteId, inSpriteRoute] = shiftSegment(extra)
this.select({ type: 'sprite', id: spriteId })
this.spriteState!.selectByRoute(inSpriteRoute)
return
case 'sounds':
const [soundId] = shiftSegment(extra)
this.select({ type: 'sound', id: soundId })
return
}
}

private getRoute(): string {
switch (this.selected.type) {
case 'stage':
return 'stage'
case 'sprite':
return `sprites/${this.selectedSpriteId}/${this.spriteState!.getRoute()}`
case 'sound':
return `sounds/${this.selectedSoundId}`
}
}

runtime = new Runtime()

constructor(private project: Project, private router: Router) {
this.selectByRoute(router.currentRoute.path)

watch(() => this.getRoute(), (path) => {
// TODO: we may want `replace` instead of `push` for resource renaming
this.router.push(path)
})
}
}
88 changes: 88 additions & 0 deletions docs/develop/in-editor-router/impl_SpriteEditor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Sprite, shiftSegment } from './base'
import { SpriteAnimationsState } from './module_AnimationsEditor'
import { SpriteCostumesState } from './module_CostumesEditor'

type Selected = {
type: 'code'
} | {
type: 'costumes'
state: SpriteCostumesState
} | {
type: 'animations'
state: SpriteAnimationsState
}

export class SpriteState {

private selectedType: 'code' | 'costumes' | 'animations' = 'code'
private costumesState = new SpriteCostumesState(this.sprite)
private animationsState = new SpriteAnimationsState(this.sprite)

get selected(): Selected {
switch (this.selectedType) {
case 'code':
return { type: 'code' }
case 'costumes':
return { type: 'costumes', state: this.costumesState }
case 'animations':
return { type: 'animations', state: this.animationsState }
}
}

select(type: 'code' | 'costumes' | 'animations') {
this.selectedType = type
}

selectByRoute(path: string) {
const [type, extra] = shiftSegment(path)
switch (type) {
case 'code':
this.select('code')
break
case 'costumes':
this.select('costumes')
this.costumesState.selectByRoute(extra)
break
case 'animations':
this.select('animations')
this.animationsState.selectByRoute(extra)
break
default:
throw new Error(`Unknown sprite state type: ${type}`)
}
}

getRoute(): string {
switch (this.selected.type) {
case 'code':
return 'code'
case 'costumes':
return `costumes/${this.costumesState.getRoute()}`
case 'animations':
return `animations/${this.animationsState.getRoute()}`
}
}

constructor(private sprite: Sprite) {}
}


function SpriteEditor(props: {
state: SpriteState
}) {
const state = props.state
function handleTabSelect(type: 'code' | 'costumes' | 'animations') {
props.state.select(type)
}

const selected = state.selected
if (selected.type === 'code') {
return `<CodeEditor />`
} else if (selected.type === 'costumes') {
return `<CostumesEditor state="${selected.state}" />`
} else if (selected.type === 'animations') {
return `<AnimationsEditor state="${selected.state}" />`
} else {
return `<div>Unknown selected type</div>`
}
}
12 changes: 12 additions & 0 deletions docs/develop/in-editor-router/impl_SpritesPanel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Sprite } from './base'
import { EditorState } from './module_EditorState'

declare const editorCtx: {
state: EditorState
}

function SpritesPanel() {
function handleSpriteClick(sprite: Sprite) {
editorCtx.state.select({ type: 'sprite', id: sprite.id })
}
}
80 changes: 80 additions & 0 deletions docs/develop/in-editor-router/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Tech design for In-editor Routing

## Route Structure

```
<editor>/stage
<editor>/stage/code
<editor>/stage/widgets
<editor>/stage/widgets/<widget_name>
<editor>/stage/backdrops
<editor>/stage/backdrops/<backdrop_name>

<editor>/sprites/<sprite_name>
<editor>/sprites/<sprite_name>/code
<editor>/sprites/<sprite_name>/costumes
<editor>/sprites/<sprite_name>/costumes/<costume_name>
<editor>/sprites/<sprite_name>/animations
<editor>/sprites/<sprite_name>/animations/<animation_name>

<editor>/sounds/<sound_name>
```

## State Management

We define the `EditorState` module to manage hoisted UI state for the editor. This "hoisted UI state" may includes:

* Current selection of sprites, backdrops, widgets, or sounds, which is reflected in both the URL and UI components
* Shared runtime state across editor components (`CodeEditor`, `EditorPreview`, `EditorPanel`, etc.)

### Why not use (Vue) Router directly as the (selection) state holder?

Routes are derived from URLs, which use resource names rather than IDs. When a resource is renamed, the URL should ideally update to reflect this change.

However, this is difficult to achieve using Router directly. Without additional state management, we cannot distinguish between a resource being renamed versus deleted, making it impossible to update the URL appropriately.

Therefore, we need a separate state layer that holds resource IDs as the source of truth. The URL and UI components are then derived from this authoritative state.

### What is the relationship between EditorState, Router and UI?

EditorState acts as the central state manager with bidirectional data flow to both the Router and UI components.

**EditorState ↔ Router:**

- **Router → EditorState**: On editor initialization, the current URL is parsed to establish the initial state
- **EditorState → Router**: State changes (sprite selection, resource renaming) trigger URL updates to maintain synchronization

**EditorState ↔ UI:**

- **UI → EditorState**: User interactions (clicks, selections) update the centralized state
- **EditorState → UI**: State changes propagate to UI components, ensuring consistent visual representation

This architecture ensures that URL, state, and UI remain synchronized while maintaining clear separation of concerns.

### What is the difference between EditorState and (model) Project?

`EditorState` manages the UI state and user interactions within the project editor, while `Project` represents the underlying data model of the project being edited.

These modules serve distinct purposes but may interact in certain scenarios. For instance, when a user selects a costume, `EditorState` updates to reflect this UI selection, while `Project` may simultaneously update to set that costume as the sprite's default.

The key distinction is that `EditorState` handles transient UI concerns (selections, view states), whereas `Project` manages persistent domain data (sprites, costumes, sounds).

### How is EditorState implemented?

`EditorState` serves as the centralized state manager that maintains current selections and other relevant UI state information for the entire editor. Its implementation is modularized across multiple components for better organization and maintainability.

The state logic for specific editor areas is implemented within their respective components. For instance, the `SpriteEditor` component contains both sprite-specific state management (`SpriteState`) and its corresponding UI logic. This co-location ensures tight coupling between related state and presentation concerns.

Each specialized module exports both its state class and UI component as a cohesive unit. The `EditorState` module then orchestrates these individual modules, assembling them into a unified instance that serves as the primary interface for other application components to interact with editor state.

### API Design

See [EditorState API](./module_EditorState.ts) for detailed API design.

### Sample Implementation & Usage

See [EditorState Implementation](./impl_EditorState.ts) for a sample implementation of the `EditorState` module.

See [SpriteEditor Implementation](./impl_SpriteEditor.ts) for a sample implementation for sub-state management within the `SpriteEditor` component, which includes both the `SpriteState` and its UI logic.

See [CodeEditor Implementation](./impl_CodeEditorUI.ts) and [SpritesPanel Implementation](./impl_SpritesPanel.ts) for a sample usage of `EditorState` within other parts of the editor.
Loading
Loading