diff --git a/spx-gui/src/components/common/markdown-vue/MarkdownView.ts b/spx-gui/src/components/common/markdown-vue/MarkdownView.ts index 37c54b79b..9339667bb 100644 --- a/spx-gui/src/components/common/markdown-vue/MarkdownView.ts +++ b/spx-gui/src/components/common/markdown-vue/MarkdownView.ts @@ -106,14 +106,18 @@ function renderHastNode(node: hast.Node, components: Components, key?: string | function renderHastElement(element: hast.Element, components: Components, key?: string | number): VNode { let props: Record let type: string | Component - let children: VRendered | (() => VRendered) + let children: VRendered | (() => VRendered) | undefined const customComponents = components.custom ?? {} if (Object.prototype.hasOwnProperty.call(customComponents, element.tagName)) { type = customComponents[element.tagName] props = hastProps2VueProps(element.properties) - // Use function slot for custom components to avoid Vue warning: - // [Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance. - children = () => element.children.map((c, i) => renderHastNode(c, components, i)) + if (element.children.length === 0) { + children = undefined + } else { + // Use function slot for custom components to avoid Vue warning: + // [Vue warn]: Non-function value encountered for default slot. Prefer function slots for better performance. + children = () => element.children.map((c, i) => renderHastNode(c, components, i)) + } } else if ( // Render code blocks with `components.codeBlock` // TODO: It may be simpler to recognize & process code blocks based on mdast instead of hast diff --git a/spx-gui/src/components/editor/code-editor/CodeLink.vue b/spx-gui/src/components/editor/code-editor/CodeLink.vue new file mode 100644 index 000000000..c1def5931 --- /dev/null +++ b/spx-gui/src/components/editor/code-editor/CodeLink.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/spx-gui/src/components/editor/code-editor/code-editor.ts b/spx-gui/src/components/editor/code-editor/code-editor.ts index 572467a6b..0d15f88d2 100644 --- a/spx-gui/src/components/editor/code-editor/code-editor.ts +++ b/spx-gui/src/components/editor/code-editor/code-editor.ts @@ -485,6 +485,11 @@ export class CodeEditor extends Disposable { if (idx !== -1) this.uis.splice(idx, 1) } + getAttachedUI() { + if (this.uis.length === 0) return null + return this.uis[this.uis.length - 1] + } + init() { this.lspClient.init() } diff --git a/spx-gui/src/components/editor/code-editor/common.ts b/spx-gui/src/components/editor/code-editor/common.ts index a292ee21d..f37c2b673 100644 --- a/spx-gui/src/components/editor/code-editor/common.ts +++ b/spx-gui/src/components/editor/code-editor/common.ts @@ -537,3 +537,18 @@ export function textDocumentId2ResourceModelId( } return null } + +export function textDocumentIdEq(a: TextDocumentIdentifier | null, b: TextDocumentIdentifier | null) { + if (a == null || b == null) return a === b + return a.uri === b.uri +} + +export function textDocumentId2CodeFileName(id: TextDocumentIdentifier) { + const codeFilePath = getCodeFilePath(id.uri) + if (stageCodeFilePaths.includes(codeFilePath)) { + return { en: 'Stage', zh: '舞台' } + } else { + const spriteName = codeFilePath.replace(/\.spx$/, '') + return { en: spriteName, zh: spriteName } + } +} diff --git a/spx-gui/src/components/editor/code-editor/context.ts b/spx-gui/src/components/editor/code-editor/context.ts index 72e5bd8ae..96509dcd1 100644 --- a/spx-gui/src/components/editor/code-editor/context.ts +++ b/spx-gui/src/components/editor/code-editor/context.ts @@ -15,6 +15,7 @@ import { CodeEditor } from './code-editor' export type CodeEditorCtx = { attachUI(ui: ICodeEditorUI): void detachUI(ui: ICodeEditorUI): void + getAttachedUI(): ICodeEditorUI | null getMonaco(): Monaco getTextDocument: (id: TextDocumentIdentifier) => TextDocument | null formatTextDocument(id: TextDocumentIdentifier): Promise @@ -159,6 +160,10 @@ export function useProvideCodeEditorCtx( if (editorRef.value == null) throw new Error('Code editor not initialized') editorRef.value.detachUI(ui) }, + getAttachedUI() { + if (editorRef.value == null) throw new Error('Code editor not initialized') + return editorRef.value.getAttachedUI() + }, getMonaco() { if (monacoRef.value == null) throw new Error('Monaco not initialized') return monacoRef.value diff --git a/spx-gui/src/components/editor/code-editor/ui/CodeEditorUI.vue b/spx-gui/src/components/editor/code-editor/ui/CodeEditorUI.vue index 866d39671..ddbe7d29d 100644 --- a/spx-gui/src/components/editor/code-editor/ui/CodeEditorUI.vue +++ b/spx-gui/src/components/editor/code-editor/ui/CodeEditorUI.vue @@ -59,6 +59,8 @@ import CopilotUI from './copilot/CopilotUI.vue' import DiagnosticsUI from './diagnostics/DiagnosticsUI.vue' import ResourceReferenceUI from './resource-reference/ResourceReferenceUI.vue' import ContextMenuUI from './context-menu/ContextMenuUI.vue' +import DocumentTabs from './document-tab/DocumentTabs.vue' +import ZoomControl from './ZoomControl.vue' const props = defineProps<{ codeFilePath: string @@ -117,13 +119,17 @@ const uiRef = computed(() => { ) }) -const monacoEditorOptions: monaco.editor.IStandaloneEditorConstructionOptions = { +const initialFontSize = 12 +const fontSize = useLocalStorage('spx-gui-code-font-size', initialFontSize) + +const monacoEditorOptions = computed(() => ({ language: 'spx', theme, tabSize, insertSpaces, + fontSize: fontSize.value, contextmenu: false -} +})) const monacEditorInitDataRef = shallowRef(null) @@ -141,6 +147,13 @@ watch( signal.throwIfAborted() ui.init(...initData) + ui.editor.onDidChangeConfiguration((e) => { + const fontSizeId = ui.monaco.editor.EditorOption.fontSize + if (e.hasChanged(fontSizeId)) { + fontSize.value = ui.editor.getOptions().get(fontSizeId) + } + }) + codeEditorCtx.attachUI(ui) signal.addEventListener('abort', () => { codeEditorCtx.detachUI(ui) @@ -200,6 +213,19 @@ watchEffect((onCleanup) => { ) signal.addEventListener('abort', endResizing) }) + +function zoomIn() { + uiRef.value.editor.trigger('keyboard', `editor.action.fontZoomIn`, {}) +} + +function zoomOut() { + uiRef.value.editor.trigger('keyboard', `editor.action.fontZoomOut`, {}) +} + +function zoomReset() { + uiRef.value.editor.updateOptions({ fontSize: initialFontSize }) + uiRef.value.editor.trigger('keyboard', `editor.action.fontZoomReset`, {}) +} @@ -323,10 +353,31 @@ watchEffect((onCleanup) => { .monaco-editor { flex: 1 1 0; min-width: 0; + margin: 12px 0; } :global(.code-editor-content-widget) { z-index: 10; // Ensure content widget is above other elements, especially cursor padding: 2px 0; // Gap between content widget and text } + +.right-sidebar { + padding: 12px 8px; + flex: 0 0 auto; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 40px; + + .document-tabs { + flex: 0 1 auto; + min-height: 0; + } + + .zoom-control { + flex: 0 0 auto; + } +} diff --git a/spx-gui/src/components/editor/code-editor/ui/ZoomControl.vue b/spx-gui/src/components/editor/code-editor/ui/ZoomControl.vue new file mode 100644 index 000000000..134beba07 --- /dev/null +++ b/spx-gui/src/components/editor/code-editor/ui/ZoomControl.vue @@ -0,0 +1,51 @@ + + + + + diff --git a/spx-gui/src/components/editor/code-editor/ui/code-editor-ui.ts b/spx-gui/src/components/editor/code-editor/ui/code-editor-ui.ts index 2f410cc20..313b6b778 100644 --- a/spx-gui/src/components/editor/code-editor/ui/code-editor-ui.ts +++ b/spx-gui/src/components/editor/code-editor/ui/code-editor-ui.ts @@ -1,5 +1,5 @@ import { uniqueId } from 'lodash' -import { ref, shallowRef } from 'vue' +import { ref, shallowReactive, shallowRef } from 'vue' import { Disposable } from '@/utils/disposable' import { timeout } from '@/utils/utils' import type { I18n } from '@/utils/i18n' @@ -21,7 +21,8 @@ import { type ResourceIdentifier, getResourceModel, type TextDocumentRange, - isRangeEmpty + isRangeEmpty, + textDocumentIdEq } from '../common' import { TextDocument } from '../text-document' import type { Monaco, MonacoEditor } from '../monaco' @@ -188,6 +189,23 @@ export class CodeEditorUI extends Disposable implements ICodeEditorUI { resourceReferenceController = new ResourceReferenceController(this) documentBase: IDocumentBase | null = null + /** Temporary text document IDs */ + private tempTextDocumentIds = shallowReactive([]) + + /** Temporary text documents */ + get tempTextDocuments() { + return this.tempTextDocumentIds.map((id) => { + const doc = this.getTextDocument(id) + if (doc == null) throw new Error(`Text document not found: ${id.uri}`) + return doc + }) + } + + closeTempTextDocuments() { + this.setActiveTextDocument(this.mainTextDocumentId) + this.tempTextDocumentIds.splice(0) + } + /** Current active text document ID */ private activeTextDocumentIdRef = shallowRef(null) @@ -204,6 +222,12 @@ export class CodeEditorUI extends Disposable implements ICodeEditorUI { this.editor.setModel(null) return } + if ( + !textDocumentIdEq(textDocument.id, this.mainTextDocumentId) && + !this.tempTextDocumentIds.some((id) => textDocumentIdEq(id, textDocument.id)) + ) { + this.tempTextDocumentIds.push(textDocument.id) + } this.activeTextDocumentIdRef.value = textDocument.id this.editor.setModel(textDocument.monacoTextModel) } diff --git a/spx-gui/src/components/editor/code-editor/ui/document-tab/DocumentTab.vue b/spx-gui/src/components/editor/code-editor/ui/document-tab/DocumentTab.vue new file mode 100644 index 000000000..0798c0ecb --- /dev/null +++ b/spx-gui/src/components/editor/code-editor/ui/document-tab/DocumentTab.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/spx-gui/src/components/editor/code-editor/ui/document-tab/DocumentTabs.vue b/spx-gui/src/components/editor/code-editor/ui/document-tab/DocumentTabs.vue new file mode 100644 index 000000000..977de752d --- /dev/null +++ b/spx-gui/src/components/editor/code-editor/ui/document-tab/DocumentTabs.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/spx-gui/src/components/editor/code-editor/ui/markdown/CodeLink.ts b/spx-gui/src/components/editor/code-editor/ui/markdown/CodeLink.ts new file mode 100644 index 000000000..762b6239a --- /dev/null +++ b/spx-gui/src/components/editor/code-editor/ui/markdown/CodeLink.ts @@ -0,0 +1,61 @@ +import { defineComponent, h } from 'vue' +import { type Range, type Position } from '../../common' +import RawCodeLink from '../../CodeLink.vue' + +export type Props = { + /** Text document URI, e.g., `file:///NiuXiaoQi.spx` */ + file: string + /** `${line},${column}`, e.g., `10,20` */ + position?: string + /** `${startLine},${startColumn}-${endLine}${endColumn}`, e.g., `10,20-12,10` */ + range?: string +} + +export default defineComponent( + (props, { slots }) => { + return function render() { + // We use render function to define `CodeLink` to properly pass `slots` to `RawCodeLink` + return h( + RawCodeLink, + { + file: { uri: props.file }, + position: parsePosition(props.position), + range: parseRange(props.range) + }, + slots + ) + } + }, + { + name: 'CodeLink', + props: { + file: { + type: String, + required: true + }, + position: { + type: String, + required: false, + default: undefined + }, + range: { + type: String, + required: false, + default: undefined + } + } + } +) + +function parsePosition(positionStr: string | undefined): Position | undefined { + if (positionStr == null || positionStr === '') return undefined + const [line, column] = positionStr.split(',').map((p) => parseInt(p.trim(), 10)) + return { line, column } +} + +function parseRange(rangeStr: string | undefined): Range | undefined { + if (rangeStr == null || rangeStr === '') return undefined + const [start, end] = rangeStr.split('-').map(parsePosition) + if (start == null || end == null) return undefined + return { start, end } +} diff --git a/spx-gui/src/components/editor/code-editor/ui/markdown/CodeLink.vue b/spx-gui/src/components/editor/code-editor/ui/markdown/CodeLink.vue deleted file mode 100644 index 099ca96b5..000000000 --- a/spx-gui/src/components/editor/code-editor/ui/markdown/CodeLink.vue +++ /dev/null @@ -1,92 +0,0 @@ - - - - - diff --git a/spx-gui/src/components/editor/code-editor/ui/markdown/MarkdownView.vue b/spx-gui/src/components/editor/code-editor/ui/markdown/MarkdownView.vue index 1f6527ee7..4c0ec3f66 100644 --- a/spx-gui/src/components/editor/code-editor/ui/markdown/MarkdownView.vue +++ b/spx-gui/src/components/editor/code-editor/ui/markdown/MarkdownView.vue @@ -4,7 +4,7 @@ import { useI18n, type LocaleMessage } from '@/utils/i18n' import MarkdownView from '@/components/common/markdown-vue/MarkdownView' import type { MarkdownStringFlag } from '../../common' import DefinitionItem from '../definition/DefinitionItem.vue' -import CodeLink from './CodeLink.vue' +import CodeLink from './CodeLink' import CodeBlock from './CodeBlock.vue' import ResourcePreview from './ResourcePreview.vue' import DiagnosticItem from './DiagnosticItem.vue' diff --git a/spx-gui/src/components/editor/panels/ConsolePanel.vue b/spx-gui/src/components/editor/panels/ConsolePanel.vue index 5121b1352..96b624365 100644 --- a/spx-gui/src/components/editor/panels/ConsolePanel.vue +++ b/spx-gui/src/components/editor/panels/ConsolePanel.vue @@ -1,10 +1,35 @@