-
Notifications
You must be signed in to change notification settings - Fork 34
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
nighca
wants to merge
1
commit into
goplus:dev
Choose a base branch
from
nighca:issue-1139
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
}) | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! }) | ||
nighca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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) | ||
}) | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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>` | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }) | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.