diff --git a/spx-gui/src/components/editor/ProjectEditor.vue b/spx-gui/src/components/editor/ProjectEditor.vue index 85b3cad5f..2e86c6d18 100644 --- a/spx-gui/src/components/editor/ProjectEditor.vue +++ b/spx-gui/src/components/editor/ProjectEditor.vue @@ -48,7 +48,7 @@ import { provide, type InjectionKey, ref, watch, shallowReactive, computed, watc import { Project } from '@/models/project' import type { UserInfo } from '@/stores/user' import SpxEditor from './SpxEditor.vue' -import SpxStage from './stage/SpxStage.vue' +import SpxStage from './preview/SpxStage.vue' import EditorPanels from './panels/EditorPanels.vue' const props = defineProps<{ diff --git a/spx-gui/src/components/editor/SpxEditor.vue b/spx-gui/src/components/editor/SpxEditor.vue index 42d0e82fc..36c45d867 100644 --- a/spx-gui/src/components/editor/SpxEditor.vue +++ b/spx-gui/src/components/editor/SpxEditor.vue @@ -1,12 +1,14 @@ diff --git a/spx-gui/src/components/editor/panels/stage/BackdropList.vue b/spx-gui/src/components/editor/panels/stage/BackdropList.vue deleted file mode 100644 index b177daf9b..000000000 --- a/spx-gui/src/components/editor/panels/stage/BackdropList.vue +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - diff --git a/spx-gui/src/components/editor/panels/stage/StageEdit.vue b/spx-gui/src/components/editor/panels/stage/StageEdit.vue deleted file mode 100644 index e020ca951..000000000 --- a/spx-gui/src/components/editor/panels/stage/StageEdit.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - diff --git a/spx-gui/src/components/editor/panels/stage/StagePanel.vue b/spx-gui/src/components/editor/panels/stage/StagePanel.vue index 589ff98b0..298ca5a6c 100644 --- a/spx-gui/src/components/editor/panels/stage/StagePanel.vue +++ b/spx-gui/src/components/editor/panels/stage/StagePanel.vue @@ -1,15 +1,122 @@ diff --git a/spx-gui/src/components/editor/panels/todo/AssetAddBtn.vue b/spx-gui/src/components/editor/panels/todo/AssetAddBtn.vue index 0e609a262..617711d30 100644 --- a/spx-gui/src/components/editor/panels/todo/AssetAddBtn.vue +++ b/spx-gui/src/components/editor/panels/todo/AssetAddBtn.vue @@ -267,7 +267,7 @@ const beforeUpload = async ( message.error(t('message.image')) return false } - editorCtx.project.stage.addBackdrop(new Backdrop(assetName, file)) + editorCtx.project.stage.setBackdrop(new Backdrop(assetName, file)) break } case 'sound': { diff --git a/spx-gui/src/components/editor/panels/todo/ImageCardCom.vue b/spx-gui/src/components/editor/panels/todo/ImageCardCom.vue deleted file mode 100644 index ed1a0d76a..000000000 --- a/spx-gui/src/components/editor/panels/todo/ImageCardCom.vue +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - diff --git a/spx-gui/src/components/editor/stage/RunnerContainer.vue b/spx-gui/src/components/editor/preview/RunnerContainer.vue similarity index 100% rename from spx-gui/src/components/editor/stage/RunnerContainer.vue rename to spx-gui/src/components/editor/preview/RunnerContainer.vue diff --git a/spx-gui/src/components/editor/stage/SpxStage.vue b/spx-gui/src/components/editor/preview/SpxStage.vue similarity index 100% rename from spx-gui/src/components/editor/stage/SpxStage.vue rename to spx-gui/src/components/editor/preview/SpxStage.vue diff --git a/spx-gui/src/components/editor/stage/stage-viewer/BackdropLayer.vue b/spx-gui/src/components/editor/preview/stage-viewer/BackdropLayer.vue similarity index 69% rename from spx-gui/src/components/editor/stage/stage-viewer/BackdropLayer.vue rename to spx-gui/src/components/editor/preview/stage-viewer/BackdropLayer.vue index 2c7af06d4..d2b459fb2 100644 --- a/spx-gui/src/components/editor/stage/stage-viewer/BackdropLayer.vue +++ b/spx-gui/src/components/editor/preview/stage-viewer/BackdropLayer.vue @@ -25,7 +25,8 @@ diff --git a/spx-gui/src/components/editor/stage/stage-viewer/Costume.vue b/spx-gui/src/components/editor/preview/stage-viewer/Costume.vue similarity index 92% rename from spx-gui/src/components/editor/stage/stage-viewer/Costume.vue rename to spx-gui/src/components/editor/preview/stage-viewer/Costume.vue index 9ea8a91e4..b5bea8adf 100644 --- a/spx-gui/src/components/editor/stage/stage-viewer/Costume.vue +++ b/spx-gui/src/components/editor/preview/stage-viewer/Costume.vue @@ -35,6 +35,8 @@ import type { Sprite } from '@/models/sprite' import type { SpriteDragMoveEvent, SpriteApperanceChangeEvent } from './common' import type { Rect } from 'konva/lib/shapes/Rect' import type { Size } from '@/models/common' +import { useImgFile } from '@/utils/file' + // ----------props & emit------------------------------------ const props = defineProps<{ sprite: Sprite @@ -57,7 +59,7 @@ const displayScale = computed( ) // ----------data related ----------------------------------- -const image = ref() +const image = useImgFile(() => currentCostume.value?.img) const costume = ref() // ----------computed properties----------------------------- // Computed spx's sprite position to konva's relative position by about changing sprite postion @@ -88,24 +90,6 @@ watch( } ) -watch( - () => currentCostume.value, - async (new_costume) => { - if (new_costume != null) { - const _image = new window.Image() - _image.src = await new_costume.img.url() - _image.onload = () => { - image.value = _image - } - } else { - image.value?.remove() - } - }, - { - immediate: true - } -) - // ----------methods----------------------------------------- /** * @description: map spx's sprite position to konva's relative position diff --git a/spx-gui/src/components/editor/stage/stage-viewer/Sprite.vue b/spx-gui/src/components/editor/preview/stage-viewer/Sprite.vue similarity index 100% rename from spx-gui/src/components/editor/stage/stage-viewer/Sprite.vue rename to spx-gui/src/components/editor/preview/stage-viewer/Sprite.vue diff --git a/spx-gui/src/components/editor/stage/stage-viewer/SpriteLayer.vue b/spx-gui/src/components/editor/preview/stage-viewer/SpriteLayer.vue similarity index 100% rename from spx-gui/src/components/editor/stage/stage-viewer/SpriteLayer.vue rename to spx-gui/src/components/editor/preview/stage-viewer/SpriteLayer.vue diff --git a/spx-gui/src/components/editor/stage/stage-viewer/StageViewer.vue b/spx-gui/src/components/editor/preview/stage-viewer/StageViewer.vue similarity index 100% rename from spx-gui/src/components/editor/stage/stage-viewer/StageViewer.vue rename to spx-gui/src/components/editor/preview/stage-viewer/StageViewer.vue diff --git a/spx-gui/src/components/editor/stage/stage-viewer/common.ts b/spx-gui/src/components/editor/preview/stage-viewer/common.ts similarity index 100% rename from spx-gui/src/components/editor/stage/stage-viewer/common.ts rename to spx-gui/src/components/editor/preview/stage-viewer/common.ts diff --git a/spx-gui/src/components/editor/stage/stage-viewer/index.ts b/spx-gui/src/components/editor/preview/stage-viewer/index.ts similarity index 100% rename from spx-gui/src/components/editor/stage/stage-viewer/index.ts rename to spx-gui/src/components/editor/preview/stage-viewer/index.ts diff --git a/spx-gui/src/components/editor/stage/StageEditor.vue b/spx-gui/src/components/editor/stage/StageEditor.vue new file mode 100644 index 000000000..13fc4d1cc --- /dev/null +++ b/spx-gui/src/components/editor/stage/StageEditor.vue @@ -0,0 +1,16 @@ + + + + + diff --git a/spx-gui/src/components/library/AssetLibrary.vue b/spx-gui/src/components/library/AssetLibrary.vue index 1a953fba0..3c95f4df3 100644 --- a/spx-gui/src/components/library/AssetLibrary.vue +++ b/spx-gui/src/components/library/AssetLibrary.vue @@ -132,7 +132,7 @@ const addAssetToProject = useMessageHandle( } case AssetType.Backdrop: { const backdrop = await asset2Backdrop(asset) - project.stage.addBackdrop(backdrop) + project.stage.setBackdrop(backdrop) break } case AssetType.Sound: { diff --git a/spx-gui/src/models/common/asset.ts b/spx-gui/src/models/common/asset.ts index c511999d0..a53ba5145 100644 --- a/spx-gui/src/models/common/asset.ts +++ b/spx-gui/src/models/common/asset.ts @@ -99,7 +99,7 @@ export function validateSoundName(name: string, project: Project | null) { export function validateBackdropName(name: string, stage: Stage | null) { const err = validateAssetName(name) if (err != null) return err - if (stage != null && stage.backdrops.find((b) => b.name === name)) + if (stage != null && stage._backdrops.find((b) => b.name === name)) return { en: `Backdrop with name ${name} already exists`, zh: '存在同名的背景' } } diff --git a/spx-gui/src/models/common/disposable.ts b/spx-gui/src/models/common/disposable.ts index fc6355fb0..64e4ec835 100644 --- a/spx-gui/src/models/common/disposable.ts +++ b/spx-gui/src/models/common/disposable.ts @@ -5,7 +5,7 @@ export type Disposer = () => void -export abstract class Disposble { +export class Disposble { _disposers: Disposer[] = [] addDisposer(disposer: Disposer) { diff --git a/spx-gui/src/models/common/file.ts b/spx-gui/src/models/common/file.ts index 3853898fb..10221edde 100644 --- a/spx-gui/src/models/common/file.ts +++ b/spx-gui/src/models/common/file.ts @@ -5,6 +5,8 @@ import { getMimeFromExt } from '@/utils/file' import { extname } from '@/utils/path' +import type { Disposer } from './disposable' +import { Cancelled } from '@/utils/exception' export type Options = { /** MIME type of file */ @@ -40,10 +42,16 @@ export class File { })) } - // TODO: remember to do URL.revokeObjectURL - async url() { + async url(onCleanup: (disposer: Disposer) => void) { + let cancelled = false + onCleanup(() => { + cancelled = true + }) const ab = await this.arrayBuffer() - return URL.createObjectURL(new Blob([ab])) + if (cancelled) throw new Cancelled() + const url = URL.createObjectURL(new Blob([ab])) + onCleanup(() => URL.revokeObjectURL(url)) + return url } } diff --git a/spx-gui/src/models/costume.ts b/spx-gui/src/models/costume.ts index 4e1967e8b..3a4a688e5 100644 --- a/spx-gui/src/models/costume.ts +++ b/spx-gui/src/models/costume.ts @@ -5,6 +5,7 @@ import { File, type Files } from './common/file' import { type Size } from './common' import type { Sprite } from './sprite' import { getCostumeName, validateCostumeName } from './common/asset' +import { Disposble } from './common/disposable' export type CostumeInits = { x?: number @@ -57,21 +58,23 @@ export class Costume { } async getSize() { - const imgUrl = await this.img.url() + const d = new Disposble() + const imgUrl = await this.img.url((fn) => d.addDisposer(fn)) return new Promise((resolve, reject) => { const img = new window.Image() + d.addDisposer(() => img.remove()) img.src = imgUrl img.onload = () => { resolve({ width: img.width / this.bitmapResolution, height: img.height / this.bitmapResolution }) - img.remove() } img.onerror = (e) => { reject(new Error(`load image failed: ${e.toString()}`)) - img.remove() } + }).finally(() => { + d.dispose() }) } diff --git a/spx-gui/src/models/stage.ts b/spx-gui/src/models/stage.ts index baf2e44bf..cba3d2185 100644 --- a/spx-gui/src/models/stage.ts +++ b/spx-gui/src/models/stage.ts @@ -47,36 +47,55 @@ export class Stage { this.code = code } - backdrops: Backdrop[] - backdropIndex: number get backdrop(): Backdrop | null { - return this.backdrops[this.backdropIndex] ?? null + return this._backdrops[this._backdropIndex] ?? null } - setbackdropIndex(backdropIndex: number) { - this.backdropIndex = backdropIndex + /** + * Set given backdrop to stage. + * Note: the backdrop's name may be altered to avoid conflict + */ + setBackdrop(backdrop: Backdrop) { + for (const b of this._backdrops) { + this.removeBackdrop(b.name) + } + this._addBackdrop(backdrop) } removeBackdrop(name: string) { - const idx = this.backdrops.findIndex((s) => s.name === name) - const [backdrop] = this.backdrops.splice(idx, 1) + const idx = this._backdrops.findIndex((s) => s.name === name) + const [backdrop] = this._backdrops.splice(idx, 1) backdrop.setStage(null) - // TODO: `this.backdropIndex`? + if (this._backdropIndex === idx) { + this._setBackdropIndex(0) + } + } + + // Currently we support at most one backdrop, so + // * fields like `backdrops`、`backdropIndex` + // * methods like `setBackdropIndex`、`addBackdrop`、`topBackdrop` + // are marked private to prevent usage from outside of models. + // Instead, we offer `setBackdrop` to manipulate backdrops. + + _backdrops: Backdrop[] + _backdropIndex: number + _setBackdropIndex(backdropIndex: number) { + this._backdropIndex = backdropIndex } /** * Add given backdrop to stage. * Note: the backdrop's name may be altered to avoid conflict */ - addBackdrop(backdrop: Backdrop) { + _addBackdrop(backdrop: Backdrop) { const newName = getBackdropName(this, backdrop.name) backdrop.setName(newName) backdrop.setStage(this) - this.backdrops.push(backdrop) + this._backdrops.push(backdrop) // TODO: `this.backdropIndex`? } - topBackdrop(name: string) { - const idx = this.backdrops.findIndex((s) => s.name === name) + _topBackdrop(name: string) { + const idx = this._backdrops.findIndex((s) => s.name === name) if (idx < 0) throw new Error(`backdrop ${name} not found`) - const [backdrop] = this.backdrops.splice(idx, 1) - this.backdrops.unshift(backdrop) + const [backdrop] = this._backdrops.splice(idx, 1) + this._backdrops.unshift(backdrop) // TODO: relation to `this.backdropIndex`? } @@ -109,8 +128,8 @@ export class Stage { constructor(code = '', inits?: Partial) { this.code = code - this.backdrops = [] - this.backdropIndex = inits?.backdropIndex ?? 0 + this._backdrops = [] + this._backdropIndex = inits?.backdropIndex ?? 0 this.mapWidth = inits?.mapWidth this.mapHeight = inits?.mapHeight this.mapMode = getMapMode(inits?.mapMode) @@ -134,7 +153,7 @@ export class Stage { }) const backdrops = (sceneConfigs ?? []).map((c) => Backdrop.load(c, files)) for (const backdrop of backdrops) { - stage.addBackdrop(backdrop) + stage._addBackdrop(backdrop) } return stage } @@ -145,12 +164,12 @@ export class Stage { if (this.code !== '') { files[stageCodeFilePath] = fromText(stageCodeFileName, this.code) } - for (const backdrop of this.backdrops) { + for (const backdrop of this._backdrops) { const [backdropConfig, backdropFiles] = backdrop.export() backdropConfigs.push(backdropConfig) Object.assign(files, backdropFiles) } - const { backdropIndex, mapWidth, mapHeight, mapMode } = this + const { _backdropIndex: backdropIndex, mapWidth, mapHeight, mapMode } = this const config: RawStageConfig = { scenes: backdropConfigs, sceneIndex: backdropIndex, diff --git a/spx-gui/src/utils/file.ts b/spx-gui/src/utils/file.ts index f453f3f0c..b8f3cc007 100644 --- a/spx-gui/src/utils/file.ts +++ b/spx-gui/src/utils/file.ts @@ -1,3 +1,6 @@ +import { ref, watch, type WatchSource } from 'vue' +import type { File } from '@/models/common/file' + /** * Map file extension to mime type. */ @@ -32,7 +35,7 @@ export type FileSelectOptions = { } function _selectFile({ accept = '', multiple = false }: FileSelectOptions) { - return new Promise((resolve) => { + return new Promise((resolve) => { const input = document.createElement('input') input.type = 'file' input.accept = accept @@ -59,8 +62,47 @@ export function selectFiles(options?: Omit) { return _selectFile({ ...options, multiple: true }) } +/** Let the user select single image */ +export function selectImg() { + const accept = imgExts.map((ext) => `.${ext}`).join(',') + return selectFile({ accept }) +} + /** Let the user select multiple images */ export function selectImgs() { const accept = imgExts.map((ext) => `.${ext}`).join(',') return selectFiles({ accept }) } + +/** Get url for File */ +export function useFileUrl(fileSource: WatchSource) { + const urlRef = ref(null) + watch( + fileSource, + (file, _, onCleanup) => { + if (file == null) return + file.url(onCleanup).then((url) => { + urlRef.value = url + }) + }, + { immediate: true } + ) + return urlRef +} + +export function useImgFile(fileSource: WatchSource) { + const urlRef = useFileUrl(fileSource) + const imgRef = ref(null) + watch(urlRef, (url, _, onCleanup) => { + onCleanup(() => { + imgRef.value?.remove() + imgRef.value = null + }) + if (url != null) { + const img = new window.Image() + img.src = url + imgRef.value = img + } + }) + return imgRef +}