From 6d87a59d960bf8cdcf183d27585a840a0585817b Mon Sep 17 00:00:00 2001 From: ash Date: Mon, 13 Jul 2026 16:12:23 +0800 Subject: [PATCH 01/10] Implement Source Editor MVP --- .../src/editor/SourceEditor.ts | 144 +++++++++++ .../pagx-playground/src/editor/index.ts | 231 ++++++++++++++++++ .../pagx-playground/src/editor/styles.ts | 211 ++++++++++++++++ playground/pagx-playground/src/index.ts | 84 ++++++- playground/pagx-playground/static/index.html | 17 ++ 5 files changed, 685 insertions(+), 2 deletions(-) create mode 100644 playground/pagx-playground/src/editor/SourceEditor.ts create mode 100644 playground/pagx-playground/src/editor/index.ts create mode 100644 playground/pagx-playground/src/editor/styles.ts diff --git a/playground/pagx-playground/src/editor/SourceEditor.ts b/playground/pagx-playground/src/editor/SourceEditor.ts new file mode 100644 index 0000000000..97ccb16f62 --- /dev/null +++ b/playground/pagx-playground/src/editor/SourceEditor.ts @@ -0,0 +1,144 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { EditorView, keymap } from '@codemirror/view'; +import { EditorState } from '@codemirror/state'; +import { basicSetup } from 'codemirror'; +import { xml } from '@codemirror/lang-xml'; +import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'; +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { tags } from '@lezer/highlight'; + +/** Syntax highlight colors matching the desktop client (VS Code Dark+ palette). */ +const pagxHighlightStyle = HighlightStyle.define([ + { tag: tags.tagName, color: '#569CD6' }, + { tag: tags.attributeName, color: '#9CDCFE' }, + { tag: tags.attributeValue, color: '#CE9178' }, + { tag: tags.string, color: '#CE9178' }, + { tag: tags.comment, color: '#6A9955' }, + { tag: tags.meta, color: '#808080' }, + { tag: tags.processingInstruction, color: '#808080' }, + { tag: tags.content, color: '#D4D4D4' }, + { tag: tags.angleBracket, color: '#569CD6' }, +]); + +/** + * Wraps a CodeMirror 6 instance for editing PAGX XML source. + * The editor is created lazily on first open to avoid impacting initial page load. + */ +export class SourceEditor { + private host: HTMLElement; + private view: EditorView | null = null; + + constructor(host: HTMLElement) { + this.host = host; + } + + /** Creates the CodeMirror view if it does not exist yet. */ + private ensureView(): void { + if (this.view !== null) { + return; + } + this.view = new EditorView({ + parent: this.host, + state: EditorState.create({ + doc: '', + extensions: [ + basicSetup, + xml(), + syntaxHighlighting(pagxHighlightStyle), + highlightSelectionMatches(), + keymap.of([...searchKeymap]), + EditorView.theme({ + '&': { + backgroundColor: '#1E1E1E', + color: '#D4D4D4', + height: '100%', + }, + '.cm-content': { + fontFamily: 'Menlo, Monaco, "Courier New", monospace', + fontSize: '13px', + caretColor: '#FFFFFF', + }, + '.cm-cursor': { + borderLeftColor: '#FFFFFF', + borderLeftWidth: '2px', + }, + '.cm-gutters': { + backgroundColor: '#1E1E1E', + color: '#6E7681', + border: 'none', + borderRight: '1px solid #3C3C3C', + }, + '.cm-activeLine': { + backgroundColor: '#2D2D2D', + }, + '.cm-activeLineGutter': { + backgroundColor: '#2D2D2D', + }, + '.cm-selectionBackground, ::selection': { + backgroundColor: '#264F78', + }, + '&.cm-focused .cm-selectionBackground': { + backgroundColor: '#264F78', + }, + }), + ], + }), + }); + } + + /** Replaces the entire document content. */ + setContent(text: string): void { + this.ensureView(); + if (this.view === null) { + return; + } + const trimmed = text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text; + this.view.dispatch({ + changes: { + from: 0, + to: this.view.state.doc.length, + insert: trimmed, + }, + }); + } + + /** Returns the current document text. */ + getContent(): string { + if (this.view === null) { + return ''; + } + return this.view.state.doc.toString(); + } + + /** Moves keyboard focus into the editor. */ + focus(): void { + if (this.view !== null) { + this.view.focus(); + } + } + + /** Releases the CodeMirror instance and frees DOM nodes. */ + destroy(): void { + if (this.view !== null) { + this.view.destroy(); + this.view = null; + } + } +} diff --git a/playground/pagx-playground/src/editor/index.ts b/playground/pagx-playground/src/editor/index.ts new file mode 100644 index 0000000000..51964bdf8b --- /dev/null +++ b/playground/pagx-playground/src/editor/index.ts @@ -0,0 +1,231 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as is" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +import { SourceEditor } from './SourceEditor'; +import { EDITOR_STYLES } from './styles'; + +const MOBILE_BREAKPOINT = 768; +const TOAST_DURATION_MS = 2000; + +let panel: HTMLElement | null = null; +let editor: SourceEditor | null = null; +let currentXmlText: string | null = null; +let toastTimer: number | undefined; +let toastEl: HTMLElement | null = null; + +/** Callbacks provided by the host application to apply/save XML changes. */ +export interface EditorCallbacks { + /** Applies XML text to the PAGX view. Returns empty string on success, error message on failure. */ + onApply: (xmlText: string) => string; + /** Applies and saves XML text to a file. Returns empty string on success, error message on failure. */ + onSave: (xmlText: string) => string; +} + +let callbacks: EditorCallbacks | null = null; + +/** Dispatched by index.ts after a PAGX file is loaded or cleared. */ +interface PagxLoadedDetail { + xmlText: string | null; +} + +declare global { + interface WindowEventMap { + 'pagx:loaded': CustomEvent; + } +} + +/** True when the keyboard event originates from an editable element. */ +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + const tag = target.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable; +} + +/** True when the editor panel is currently shown. */ +function isPanelOpen(): boolean { + return panel !== null && panel.classList.contains('visible'); +} + +function showToast(message: string, success: boolean): void { + if (toastEl === null) { + return; + } + toastEl.textContent = message; + toastEl.className = `editor-toast visible ${success ? 'success' : 'error'}`; + window.clearTimeout(toastTimer); + toastTimer = window.setTimeout(() => { + if (toastEl !== null) { + toastEl.className = 'editor-toast'; + } + }, TOAST_DURATION_MS); +} + +function openPanel(): void { + if (panel === null) { + return; + } + if (editor === null) { + const host = panel.querySelector('.editor-host'); + if (host instanceof HTMLElement) { + editor = new SourceEditor(host); + } + } + if (editor !== null && currentXmlText !== null) { + editor.setContent(currentXmlText); + } + panel.classList.add('visible'); + const container = document.getElementById('container'); + if (container !== null) { + container.classList.add('with-editor'); + } +} + +function closePanel(): void { + if (panel === null) { + return; + } + panel.classList.remove('visible'); + const container = document.getElementById('container'); + if (container !== null) { + container.classList.remove('with-editor'); + } +} + +function togglePanel(): void { + if (isPanelOpen()) { + closePanel(); + } else { + openPanel(); + } +} + +function handleKeydown(event: KeyboardEvent): void { + if (event.key !== 'l' && event.key !== 'L') { + return; + } + // Ignore when the user is typing inside an input/textarea/contenteditable. + if (isEditableTarget(event.target)) { + return; + } + // Ignore modifier combos (Ctrl+L, Shift+L, etc.) - only bare L triggers. + if (event.ctrlKey || event.metaKey || event.altKey) { + return; + } + // No file loaded yet - silently ignore, matching desktop "enabled: hasPAGFile". + if (currentXmlText === null) { + return; + } + // Mobile screens are too narrow for side-by-side layout. + if (window.innerWidth < MOBILE_BREAKPOINT) { + showToast('Please use desktop to edit source', false); + return; + } + event.preventDefault(); + togglePanel(); +} + +function handlePagxLoaded(event: Event): void { + const detail = (event as CustomEvent).detail; + currentXmlText = detail ? detail.xmlText : null; + if (currentXmlText === null) { + // File cleared (goHome) - close panel and clear editor content. + if (isPanelOpen()) { + closePanel(); + } + if (editor !== null) { + editor.setContent(''); + } + } else if (isPanelOpen() && editor !== null) { + // Panel already open and a new file loaded - refresh content. + editor.setContent(currentXmlText); + } +} + +function handleDiscard(): void { + if (editor === null) { + return; + } + if (currentXmlText !== null) { + editor.setContent(currentXmlText); + } + showToast('Changes discarded', true); +} + +function handleApply(): void { + if (editor === null || callbacks === null) { + return; + } + const xmlText = editor.getContent(); + const error = callbacks.onApply(xmlText); + showToast(error === '' ? 'Changes applied' : error, error === ''); +} + +function handleSave(): void { + if (editor === null || callbacks === null) { + return; + } + const xmlText = editor.getContent(); + const error = callbacks.onSave(xmlText); + showToast(error === '' ? 'File saved' : error, error === ''); +} + +/** + * Initializes the Source Editor module. Wires up DOM elements, keyboard + * shortcut (L to toggle) and the pagx:loaded event. Safe to call once + * after DOM is ready. + */ +export function init(cb: EditorCallbacks): void { + callbacks = cb; + // Inject editor styles once. CSS lives in styles.ts to keep the module self-contained + // without requiring a rollup CSS plugin. + const styleEl = document.createElement('style'); + styleEl.textContent = EDITOR_STYLES; + document.head.appendChild(styleEl); + + panel = document.getElementById('editor-panel'); + if (panel === null) { + console.warn('SourceEditor: #editor-panel element not found, skipping init.'); + return; + } + const closeBtn = panel.querySelector('.editor-close-btn'); + if (closeBtn !== null) { + closeBtn.addEventListener('click', closePanel); + } + const discardBtn = panel.querySelector('.editor-btn.discard'); + if (discardBtn !== null) { + discardBtn.addEventListener('click', handleDiscard); + } + const applyBtn = panel.querySelector('.editor-btn.apply'); + if (applyBtn !== null) { + applyBtn.addEventListener('click', handleApply); + } + const saveBtn = panel.querySelector('.editor-btn.save'); + if (saveBtn !== null) { + saveBtn.addEventListener('click', handleSave); + } + toastEl = document.querySelector('.editor-toast'); + if (toastEl === null) { + toastEl = document.createElement('div'); + toastEl.className = 'editor-toast'; + document.body.appendChild(toastEl); + } + document.addEventListener('keydown', handleKeydown); + window.addEventListener('pagx:loaded', handlePagxLoaded); +} diff --git a/playground/pagx-playground/src/editor/styles.ts b/playground/pagx-playground/src/editor/styles.ts new file mode 100644 index 0000000000..807ac27150 --- /dev/null +++ b/playground/pagx-playground/src/editor/styles.ts @@ -0,0 +1,211 @@ +///////////////////////////////////////////////////////////////////////////////////////////////// +// +// Tencent is pleased to support the open source community by making libpag available. +// +// Copyright (C) 2026 Tencent. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file +// except in compliance with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// unless required by applicable law or agreed to in writing, software distributed under the +// license is distributed on an "as IS" basis, without warranties or conditions of any kind, +// either express or implied. see the license for the specific language governing permissions +// and limitations under the license. +// +///////////////////////////////////////////////////////////////////////////////////////////////// + +/** CSS rules for the editor panel, injected at runtime to avoid a separate rollup CSS plugin. */ +export const EDITOR_STYLES = ` +#editor-panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 50%; + display: none; + flex-direction: column; + background: #1E1E1E; + border-left: 1px solid #3C3C3C; + z-index: 10; +} + +#editor-panel.visible { + display: flex; +} + +.container.with-editor { + width: 50%; + margin: 0; +} + +#editor-panel .editor-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding: 0 12px; + background: #252526; + border-bottom: 1px solid #3C3C3C; + flex-shrink: 0; +} + +#editor-panel .editor-title { + color: #CCCCCC; + font-size: 13px; + font-weight: 500; + user-select: none; +} + +#editor-panel .editor-close-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: #CCCCCC; + cursor: pointer; + border-radius: 4px; + padding: 0; +} + +#editor-panel .editor-close-btn:hover { + background: #3C3C3C; +} + +#editor-panel .editor-host { + flex: 1; + overflow: hidden; + position: relative; +} + +#editor-panel .editor-host .cm-editor { + height: 100%; + font-size: 13px; + font-family: Menlo, Monaco, 'Courier New', monospace; +} + +#editor-panel .editor-host .cm-scroller { + overflow: auto; +} + +#editor-panel .editor-host .cm-gutters { + background: #1E1E1E; + border-right: 1px solid #3C3C3C; + color: #6E7681; +} + +#editor-panel .editor-host .cm-focused { + outline: none; +} + +.editor-toast { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + padding: 8px 16px; + border-radius: 18px; + color: #FFFFFF; + font-size: 13px; + z-index: 1000; + opacity: 0; + transition: opacity 0.2s ease-in-out; + pointer-events: none; + white-space: nowrap; +} + +.editor-toast.visible { + opacity: 0.95; +} + +.editor-toast.success { + background: #2E7D32; +} + +.editor-toast.error { + background: #C62828; +} + +#editor-panel .editor-button-bar { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + height: 48px; + background: #16161D; + border-top: 1px solid #3C3C3C; + flex-shrink: 0; +} + +#editor-panel .editor-btn { + width: 80px; + height: 32px; + border: 1px solid; + border-radius: 4px; + color: #FFFFFF; + font-size: 12px; + cursor: pointer; + transition: background 0.1s, transform 0.1s; + padding: 0; +} + +#editor-panel .editor-btn:hover { + transform: scale(1.05); +} + +#editor-panel .editor-btn:active { + transform: scale(1.0); +} + +#editor-panel .editor-btn.discard { + background: #3C3C3C; + border-color: #4B4B5A; +} + +#editor-panel .editor-btn.discard:hover { + background: #5C5C6A; + border-color: #8B8B9A; +} + +#editor-panel .editor-btn.apply { + background: #448EF9; + border-color: #5BA3FF; +} + +#editor-panel .editor-btn.apply:hover { + background: #5BA3FF; + border-color: #8BC4FF; +} + +#editor-panel .editor-btn.save { + background: #388E3C; + border-color: #4CAF50; +} + +#editor-panel .editor-btn.save:hover { + background: #4CAF50; + border-color: #81C784; +} + +#editor-panel .editor-host .cm-scroller::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +#editor-panel .editor-host .cm-scroller::-webkit-scrollbar-thumb { + background: rgba(75, 75, 90, 0.67); + border-radius: 4px; +} + +#editor-panel .editor-host .cm-scroller::-webkit-scrollbar-track { + background: transparent; +} + +#editor-panel .editor-host .cm-scroller::-webkit-scrollbar-thumb:hover { + background: rgba(90, 90, 110, 0.8); +} +`; diff --git a/playground/pagx-playground/src/index.ts b/playground/pagx-playground/src/index.ts index 02f6cd482c..e6b02aa250 100644 --- a/playground/pagx-playground/src/index.ts +++ b/playground/pagx-playground/src/index.ts @@ -18,6 +18,7 @@ import { PAGXInit } from '../wasm-mt/pagx-viewer.esm'; import type { PAGXView, PAGXModule } from '../../pagx-viewer/src/ts/pagx'; +import { init as initEditor } from './editor'; interface I18nStrings { dropText: string; @@ -820,6 +821,9 @@ function goHome(pushHistory: boolean = true): void { if (pushHistory) { history.pushState(null, '', window.location.pathname); } + + // Notify the Source Editor module that the document has been cleared. + window.dispatchEvent(new CustomEvent('pagx:loaded', { detail: { xmlText: null } })); } function isSafeRelativePath(path: string): boolean { @@ -883,6 +887,29 @@ async function loadPAGXData(data: Uint8Array, name: string, baseURL: string) { toolbar.classList.remove('hidden'); navBtns.classList.add('hidden'); document.title = 'PAGX Playground - ' + name; + currentFileName = name; + + // Notify the Source Editor module that a new XML document is available. + const decoder = new TextDecoder('utf-8'); + const xmlText = decoder.decode(data); + window.dispatchEvent(new CustomEvent('pagx:loaded', { detail: { xmlText } })); +} + +const LOADING_TIMEOUT_MS = 60000; + +function withTimeout(promise: Promise, ms: number): Promise { + let timer: number | undefined; + const timeout = new Promise((_, reject) => { + timer = window.setTimeout( + () => reject(new Error('Loading timed out. Please check your network and try again.')), + ms + ); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }) as Promise; } async function prepareForLoading(): Promise { @@ -901,7 +928,17 @@ async function prepareForLoading(): Promise { } const loadingStartTime = Date.now(); - await Promise.all([wasmLoadPromise, fontLoadPromise]); + try { + await withTimeout( + Promise.all([wasmLoadPromise, fontLoadPromise]), + LOADING_TIMEOUT_MS + ); + } catch (error) { + // Reset promises so the next attempt can retry from scratch. + wasmLoadPromise = null; + fontLoadPromise = null; + throw error; + } updateProgressUI(); const elapsed = Date.now() - loadingStartTime; @@ -922,7 +959,8 @@ async function loadPAGXFile(file: File) { history.replaceState(null, '', window.location.pathname); } catch (error) { console.error('Failed to load PAGX file:', error); - showErrorUI(t().errorFormat); + const message = error instanceof Error ? error.message : t().errorFormat; + showErrorUI(message); } } @@ -1125,6 +1163,7 @@ function applyI18n(): void { let sampleFiles: string[] = []; let currentPlayingFile: string | null = null; +let currentFileName: string = 'export.pagx'; async function loadSampleList(): Promise { if (sampleFiles.length > 0) { @@ -1250,6 +1289,47 @@ if (typeof window !== 'undefined') { if (sampleName) { loadPAGXSample(sampleName, false); } + + // Initialize the Source Editor module (keyboard shortcut L to toggle). + initEditor({ + onApply: (xmlText: string): string => { + if (playgroundState.pagxView === null) { + return 'PAGXView not initialized'; + } + try { + const data = new TextEncoder().encode(xmlText); + playgroundState.pagxView.parsePAGX(data); + playgroundState.pagxView.buildLayers(); + playgroundState.pagxView.draw(); + return ''; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + }, + onSave: (xmlText: string): string => { + if (playgroundState.pagxView === null) { + return 'PAGXView not initialized'; + } + try { + const data = new TextEncoder().encode(xmlText); + playgroundState.pagxView.parsePAGX(data); + playgroundState.pagxView.buildLayers(); + playgroundState.pagxView.draw(); + const blob = new Blob([xmlText], { type: 'application/xml' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = currentFileName.endsWith('.pagx') ? currentFileName : currentFileName + '.pagx'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + return ''; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + }, + }); }; // Observe container resize. The C++ PAGXView::draw() now auto-detects diff --git a/playground/pagx-playground/static/index.html b/playground/pagx-playground/static/index.html index e40bb4b4a5..645fe43364 100644 --- a/playground/pagx-playground/static/index.html +++ b/playground/pagx-playground/static/index.html @@ -86,6 +86,23 @@ +
+
+ Source Editor + +
+
+
+ + + +
+
From c22d8a95584e6aa885c0ceb5663086d4c70e4c09 Mon Sep 17 00:00:00 2001 From: ash Date: Mon, 13 Jul 2026 20:01:11 +0800 Subject: [PATCH 02/10] mplement command mode and fix display bug caused by coordinate transformation error --- .../src/editor/SourceEditor.ts | 27 +++++++++++-------- .../pagx-playground/src/editor/styles.ts | 25 +++++++++++++++++ playground/pagx-viewer/src/cpp/PAGXView.cpp | 4 +-- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/playground/pagx-playground/src/editor/SourceEditor.ts b/playground/pagx-playground/src/editor/SourceEditor.ts index 97ccb16f62..d5e8452c0c 100644 --- a/playground/pagx-playground/src/editor/SourceEditor.ts +++ b/playground/pagx-playground/src/editor/SourceEditor.ts @@ -18,10 +18,11 @@ import { EditorView, keymap } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; -import { basicSetup } from 'codemirror'; +import { minimalSetup } from 'codemirror'; +import { defaultKeymap, historyKeymap } from '@codemirror/commands'; import { xml } from '@codemirror/lang-xml'; import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'; -import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'; +import { highlightSelectionMatches } from '@codemirror/search'; import { tags } from '@lezer/highlight'; /** Syntax highlight colors matching the desktop client (VS Code Dark+ palette). */ @@ -39,7 +40,7 @@ const pagxHighlightStyle = HighlightStyle.define([ /** * Wraps a CodeMirror 6 instance for editing PAGX XML source. - * The editor is created lazily on first open to avoid impacting initial page load. + * Search relies on the browser's built-in Ctrl+F. */ export class SourceEditor { private host: HTMLElement; @@ -47,23 +48,23 @@ export class SourceEditor { constructor(host: HTMLElement) { this.host = host; + this.createView(); } - /** Creates the CodeMirror view if it does not exist yet. */ - private ensureView(): void { - if (this.view !== null) { - return; - } + private createView(): void { this.view = new EditorView({ parent: this.host, state: EditorState.create({ doc: '', extensions: [ - basicSetup, + minimalSetup, xml(), syntaxHighlighting(pagxHighlightStyle), highlightSelectionMatches(), - keymap.of([...searchKeymap]), + // basicSetup includes searchKeymap which intercepts Ctrl+F. Use minimalSetup + // (which omits it) and add only the keymaps we need, so the browser's + // built-in Ctrl+F is free to handle search. + keymap.of([...defaultKeymap, ...historyKeymap]), EditorView.theme({ '&': { backgroundColor: '#1E1E1E', @@ -105,7 +106,6 @@ export class SourceEditor { /** Replaces the entire document content. */ setContent(text: string): void { - this.ensureView(); if (this.view === null) { return; } @@ -134,6 +134,11 @@ export class SourceEditor { } } + /** Returns the underlying CodeMirror view, or null if not yet created. */ + getView(): EditorView | null { + return this.view; + } + /** Releases the CodeMirror instance and frees DOM nodes. */ destroy(): void { if (this.view !== null) { diff --git a/playground/pagx-playground/src/editor/styles.ts b/playground/pagx-playground/src/editor/styles.ts index 807ac27150..9d2d20855b 100644 --- a/playground/pagx-playground/src/editor/styles.ts +++ b/playground/pagx-playground/src/editor/styles.ts @@ -92,6 +92,11 @@ export const EDITOR_STYLES = ` overflow: auto; } +/* Fix white square at scrollbar corner */ +#editor-panel .editor-host .cm-scroller::-webkit-scrollbar-corner { + background: #1E1E1E; +} + #editor-panel .editor-host .cm-gutters { background: #1E1E1E; border-right: 1px solid #3C3C3C; @@ -102,6 +107,26 @@ export const EDITOR_STYLES = ` outline: none; } +/* Selection highlight */ +#editor-panel .editor-host .cm-editor .cm-selectionBackground, +#editor-panel .editor-host .cm-editor.cm-focused .cm-selectionBackground, +#editor-panel .editor-host .cm-editor ::selection { + background-color: rgba(68, 142, 249, 0.35) !important; +} + +#editor-panel .editor-host .cm-editor .cm-selectionLayer .cm-selectionBackground { + background-color: rgba(68, 142, 249, 0.35); +} + +/* highlightSelectionMatches - subtle highlight for matching text under cursor */ +#editor-panel .editor-host .cm-editor .cm-selectionMatch { + background-color: rgba(234, 179, 8, 0.15); +} + +#editor-panel .editor-host .cm-editor .cm-selectionMatch-selected { + background-color: rgba(68, 142, 249, 0.3); +} + .editor-toast { position: fixed; top: 16px; diff --git a/playground/pagx-viewer/src/cpp/PAGXView.cpp b/playground/pagx-viewer/src/cpp/PAGXView.cpp index 880e115552..75af3df58b 100644 --- a/playground/pagx-viewer/src/cpp/PAGXView.cpp +++ b/playground/pagx-viewer/src/cpp/PAGXView.cpp @@ -146,8 +146,8 @@ void PAGXView::buildLayers() { // TODO: Remove ResolveAllImagePatternMatrices() and ResolveAllGradientCoordinates() after the // pagx exporter adapts to relative coordinates for image and gradient fills. Currently we force // them to absolute coordinates to ensure correct rendering. - ResolveAllImagePatternMatrices(document.get()); - ResolveAllGradientCoordinates(document.get()); + // ResolveAllImagePatternMatrices(document.get()); + // ResolveAllGradientCoordinates(document.get()); document->applyLayout(&fontConfig); scene = PAGScene::Make(document); if (scene == nullptr) { From 6268f50492e7688c381abeeb82eb6ca604834f57 Mon Sep 17 00:00:00 2001 From: ash Date: Tue, 14 Jul 2026 10:45:11 +0800 Subject: [PATCH 03/10] Add simple XML detection and new icons --- .../pagx-playground/src/editor/index.ts | 25 ++++++++++++++++++- playground/pagx-playground/src/index.ts | 9 ++++++- playground/pagx-playground/static/index.html | 6 +++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/playground/pagx-playground/src/editor/index.ts b/playground/pagx-playground/src/editor/index.ts index 51964bdf8b..1811bd6797 100644 --- a/playground/pagx-playground/src/editor/index.ts +++ b/playground/pagx-playground/src/editor/index.ts @@ -108,7 +108,7 @@ function closePanel(): void { } } -function togglePanel(): void { +export function togglePanel(): void { if (isPanelOpen()) { closePanel(); } else { @@ -158,6 +158,19 @@ function handlePagxLoaded(event: Event): void { } } +/** Validates XML well-formedness using DOMParser. Returns empty string on success, error message on failure. */ +function validateXml(xmlText: string): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(xmlText, 'application/xml'); + const parseError = doc.querySelector('parsererror'); + if (parseError) { + const errorText = parseError.textContent || 'Invalid XML'; + const firstLine = errorText.trim().split('\n')[0].trim(); + return firstLine || 'Invalid XML format'; + } + return ''; +} + function handleDiscard(): void { if (editor === null) { return; @@ -173,6 +186,11 @@ function handleApply(): void { return; } const xmlText = editor.getContent(); + const validationError = validateXml(xmlText); + if (validationError !== '') { + showToast(validationError, false); + return; + } const error = callbacks.onApply(xmlText); showToast(error === '' ? 'Changes applied' : error, error === ''); } @@ -182,6 +200,11 @@ function handleSave(): void { return; } const xmlText = editor.getContent(); + const validationError = validateXml(xmlText); + if (validationError !== '') { + showToast(validationError, false); + return; + } const error = callbacks.onSave(xmlText); showToast(error === '' ? 'File saved' : error, error === ''); } diff --git a/playground/pagx-playground/src/index.ts b/playground/pagx-playground/src/index.ts index e6b02aa250..747a12a61a 100644 --- a/playground/pagx-playground/src/index.ts +++ b/playground/pagx-playground/src/index.ts @@ -18,7 +18,7 @@ import { PAGXInit } from '../wasm-mt/pagx-viewer.esm'; import type { PAGXView, PAGXModule } from '../../pagx-viewer/src/ts/pagx'; -import { init as initEditor } from './editor'; +import { init as initEditor, togglePanel as toggleEditorPanel } from './editor'; interface I18nStrings { dropText: string; @@ -1330,6 +1330,13 @@ if (typeof window !== 'undefined') { } }, }); + + const sourceEditorBtn = document.getElementById('source-editor-btn'); + if (sourceEditorBtn) { + sourceEditorBtn.addEventListener('click', () => { + toggleEditorPanel(); + }); + } }; // Observe container resize. The C++ PAGXView::draw() now auto-detects diff --git a/playground/pagx-playground/static/index.html b/playground/pagx-playground/static/index.html index 645fe43364..80843be9b6 100644 --- a/playground/pagx-playground/static/index.html +++ b/playground/pagx-playground/static/index.html @@ -77,6 +77,12 @@ +
- +

PAGX Playground

@@ -141,6 +141,25 @@

PAGX Playground

+
+
Source Editor
From 01e3e652f398027c340ca81a5847c1c3125b26f4 Mon Sep 17 00:00:00 2001 From: ash Date: Thu, 16 Jul 2026 15:21:07 +0800 Subject: [PATCH 09/10] code format --- playground/pagx-viewer/src/cpp/binding.cpp | 24 ++++++++-------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/playground/pagx-viewer/src/cpp/binding.cpp b/playground/pagx-viewer/src/cpp/binding.cpp index ab7917213e..e701895834 100644 --- a/playground/pagx-viewer/src/cpp/binding.cpp +++ b/playground/pagx-viewer/src/cpp/binding.cpp @@ -51,22 +51,16 @@ EMSCRIPTEN_BINDINGS(PAGXPlayground) { .function("_pause", &pagx::PAGXView::pause) .function("_isPlaying", &pagx::PAGXView::isPlaying) // Wrap int64_t returns/params as double: emscripten::bind does not support 64-bit integers. - .function( - "_currentTimeMicros", - optional_override([](pagx::PAGXView& self) -> double { - return static_cast(self.currentTimeMicros()); - })) - .function( - "_durationMicros", - optional_override([](pagx::PAGXView& self) -> double { - return static_cast(self.durationMicros()); - })) + .function("_currentTimeMicros", optional_override([](pagx::PAGXView& self) -> double { + return static_cast(self.currentTimeMicros()); + })) + .function("_durationMicros", optional_override([](pagx::PAGXView& self) -> double { + return static_cast(self.durationMicros()); + })) .function("_currentFrameRate", &pagx::PAGXView::currentFrameRate) - .function( - "_setCurrentTimeMicros", - optional_override([](pagx::PAGXView& self, double micros) { - self.setCurrentTimeMicros(static_cast(micros)); - })) + .function("_setCurrentTimeMicros", optional_override([](pagx::PAGXView& self, double micros) { + self.setCurrentTimeMicros(static_cast(micros)); + })) .function("_setLoop", &pagx::PAGXView::setLoop) .function("_isLoop", &pagx::PAGXView::isLoop); } From 76f66039ceca984b66853db93a794fa014ee1306 Mon Sep 17 00:00:00 2001 From: ash Date: Mon, 20 Jul 2026 17:02:58 +0800 Subject: [PATCH 10/10] Update according to review comments --- playground/pagx-playground/package.json | 5 +- playground/pagx-playground/scripts/rollup.js | 4 +- .../src/editor/SourceEditor.ts | 19 ++++- playground/pagx-playground/src/index.ts | 74 +++++++++++++------ playground/pagx-viewer/src/cpp/PAGXView.cpp | 8 +- playground/pagx-viewer/src/cpp/PAGXView.h | 2 +- playground/pagx-viewer/src/cpp/binding.cpp | 2 +- playground/pagx-viewer/src/ts/pagx-view.ts | 4 +- playground/pagx-viewer/src/ts/types.ts | 2 +- 9 files changed, 81 insertions(+), 39 deletions(-) diff --git a/playground/pagx-playground/package.json b/playground/pagx-playground/package.json index fbf136db8c..bddb4ae8e3 100644 --- a/playground/pagx-playground/package.json +++ b/playground/pagx-playground/package.json @@ -11,8 +11,8 @@ "build": "npm run prebuild && rollup -c ./scripts/rollup.js", "build:release": "node scripts/prebuild.js --release && BUILD_MODE=release rollup -c ./scripts/rollup.js", "prebuild:st": "node scripts/prebuild.js --arch st", - "build:st": "npm run prebuild:st && ARCH=wasm rollup -c ./scripts/rollup.js", - "build:release:st": "node scripts/prebuild.js --arch st --release && ARCH=wasm BUILD_MODE=release rollup -c ./scripts/rollup.js", + "build:st": "npm run prebuild:st && ARCH=st rollup -c ./scripts/rollup.js", + "build:release:st": "node scripts/prebuild.js --arch st --release && ARCH=st BUILD_MODE=release rollup -c ./scripts/rollup.js", "publish": "node scripts/publish.js", "server": "node static/server.js" }, @@ -38,7 +38,6 @@ "@codemirror/state": "^6.7.1", "@codemirror/view": "^6.43.6", "@lezer/highlight": "^1.2.3", - "codemirror": "^6.0.2", "express": "^4.21.1", "highlight.js": "^11.9.0", "marked": "^15.0.0", diff --git a/playground/pagx-playground/scripts/rollup.js b/playground/pagx-playground/scripts/rollup.js index 4a75005896..1033e61294 100644 --- a/playground/pagx-playground/scripts/rollup.js +++ b/playground/pagx-playground/scripts/rollup.js @@ -38,9 +38,9 @@ const banner = existsSync(fileHeaderPath) ? readFileSync(fileHeaderPath, 'utf-8' const isRelease = process.env.BUILD_MODE === 'release'; // Detect the pagx-viewer arch by checking which glue file was copied into wasm-mt/. -// This must stay in sync with prebuild.js's auto-detection logic. +// This must stay in sync with prebuild.js's auto-detection logic (both accept ARCH=st|mt). function detectGlueInfix() { - if (process.env.ARCH === 'wasm') return '.st'; + if (process.env.ARCH === 'st') return '.st'; if (process.env.ARCH === 'mt') return ''; // No explicit ARCH env: check which .esm.js actually exists. if (!existsSync(path.join(playgroundRoot, 'wasm-mt/pagx-viewer.esm.js')) diff --git a/playground/pagx-playground/src/editor/SourceEditor.ts b/playground/pagx-playground/src/editor/SourceEditor.ts index b6f12612ee..e7880d3511 100644 --- a/playground/pagx-playground/src/editor/SourceEditor.ts +++ b/playground/pagx-playground/src/editor/SourceEditor.ts @@ -16,15 +16,26 @@ // ///////////////////////////////////////////////////////////////////////////////////////////////// -import { EditorView, keymap, lineNumbers } from '@codemirror/view'; +import { EditorView, keymap, lineNumbers, highlightSpecialChars, drawSelection } from '@codemirror/view'; import { EditorState } from '@codemirror/state'; -import { minimalSetup } from 'codemirror'; -import { defaultKeymap, historyKeymap } from '@codemirror/commands'; +import { defaultKeymap, historyKeymap, history } from '@codemirror/commands'; import { xml } from '@codemirror/lang-xml'; -import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'; +import { syntaxHighlighting, HighlightStyle, defaultHighlightStyle } from '@codemirror/language'; import { highlightSelectionMatches } from '@codemirror/search'; import { tags } from '@lezer/highlight'; +// Inline replacement for the `codemirror` meta-package's `minimalSetup`. We depend directly on +// the @codemirror/* sub-packages so npm never has to reconcile the meta-package's own version +// pins against ours (mismatched pins spawn duplicate EditorState instances, which then trip +// instanceof checks inside CodeMirror at runtime). Sourced from codemirror v6's minimalSetup. +const minimalSetup = [ + highlightSpecialChars(), + history(), + drawSelection(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([...defaultKeymap, ...historyKeymap]), +]; + /** Syntax highlight colors matching the desktop client (VS Code Dark+ palette). */ const pagxHighlightStyle = HighlightStyle.define([ { tag: tags.tagName, color: '#569CD6' }, diff --git a/playground/pagx-playground/src/index.ts b/playground/pagx-playground/src/index.ts index b708951203..4d84278b82 100644 --- a/playground/pagx-playground/src/index.ts +++ b/playground/pagx-playground/src/index.ts @@ -849,7 +849,7 @@ function formatTime(microseconds: number): string { function getCurrentFrame(): number { const view = playgroundState.pagxView; if (!view) return 0; - const rate = view.currentFrameRate(); + const rate = view.frameRate(); if (rate <= 0) return 0; return Math.round(Math.max(0, view.currentTimeMicros()) * rate / 1_000_000); } @@ -857,7 +857,7 @@ function getCurrentFrame(): number { function getTotalFrames(): number { const view = playgroundState.pagxView; if (!view) return 0; - const rate = view.currentFrameRate(); + const rate = view.frameRate(); if (rate <= 0) return 0; return Math.ceil(view.durationMicros() * rate / 1_000_000); } @@ -951,7 +951,7 @@ function stepFrame(direction: number): void { if (!view) { return; } - const rate = view.currentFrameRate(); + const rate = view.frameRate(); const duration = view.durationMicros(); if (rate <= 0 || duration <= 0) { return; @@ -964,12 +964,9 @@ function stepFrame(direction: number): void { } function hidePlaybackUI(): void { - const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; - const toolbar = document.getElementById('toolbar') as HTMLDivElement; - const playbackBar = document.getElementById('playback-bar') as HTMLDivElement; - canvas.classList.add('hidden'); - toolbar.classList.add('hidden'); - playbackBar.classList.add('hidden'); + document.getElementById('pagx-canvas')?.classList.add('hidden'); + document.getElementById('toolbar')?.classList.add('hidden'); + document.getElementById('playback-bar')?.classList.add('hidden'); } const DEFAULT_TITLE = 'PAGX Playground'; @@ -979,14 +976,10 @@ function goHome(pushHistory: boolean = true): void { playgroundState.pagxView.clear(); gestureManager.resetTransform(playgroundState); } - const canvas = document.getElementById('pagx-canvas') as HTMLCanvasElement; - const toolbar = document.getElementById('toolbar') as HTMLDivElement; - const navBtns = document.getElementById('nav-btns') as HTMLDivElement; - const playbackBar = document.getElementById('playback-bar') as HTMLDivElement; - canvas.classList.add('hidden'); - toolbar.classList.add('hidden'); - playbackBar.classList.add('hidden'); - navBtns.classList.remove('hidden'); + document.getElementById('pagx-canvas')?.classList.add('hidden'); + document.getElementById('toolbar')?.classList.add('hidden'); + document.getElementById('playback-bar')?.classList.add('hidden'); + document.getElementById('nav-btns')?.classList.remove('hidden'); document.title = DEFAULT_TITLE; showDropZoneUI(); currentPlayingFile = null; @@ -1039,6 +1032,17 @@ async function loadExternalFiles(baseURL: string): Promise { await Promise.all(fetches); } +// Shared post-reparse UI refresh. Any call site that runs parsePAGX + buildLayers must invoke +// this so the canvas backing store, playback bar visibility and playback UI values stay in sync +// with the newly parsed document; forgetting it leaves the UI stuck on the previous file's +// duration/dimensions. +function refreshUIAfterReparse(): void { + updateSize(); + // showPlaybackBar handles the static-image case (duration === 0 → hide) and refreshes the + // slider / time / loop icon internally when the bar becomes visible. + showPlaybackBar(); +} + async function loadPAGXData(data: Uint8Array, name: string, baseURL: string) { const navBtns = document.getElementById('nav-btns') as HTMLDivElement; const toolbar = document.getElementById('toolbar') as HTMLDivElement; @@ -1056,14 +1060,13 @@ async function loadPAGXData(data: Uint8Array, name: string, baseURL: string) { // opened file starts fresh instead of inheriting the previous file's loop toggle. playgroundState.pagxView.setLoop(true); gestureManager.resetTransform(playgroundState); - updateSize(); + refreshUIAfterReparse(); // Draw the first frame before showing canvas to avoid flashing old content playgroundState.pagxView.draw(); hideDropZone(); canvas.classList.remove('hidden'); toolbar.classList.remove('hidden'); navBtns.classList.add('hidden'); - showPlaybackBar(); document.title = 'PAGX Playground - ' + name; currentFileName = name; @@ -1342,19 +1345,35 @@ function setupPlaybackControls(): void { }); } - // Keyboard shortcut: Space for play/pause + // Keyboard shortcuts: Space toggles play/pause, ArrowLeft/ArrowRight step one frame. + // Guards are shared across shortcuts: + // - text-entry targets (input/textarea/contentEditable) never trigger playback shortcuts; + // - when the progress slider itself holds focus, ArrowLeft/Right must fall through to the + // native range control (fine-grained scrub) rather than jumping a whole frame; Space + // however still toggles playback so the user can pause without leaving the slider; + // - no file loaded (canvas hidden) → do nothing so the shortcut can't be misused before + // the drop-zone is dismissed. document.addEventListener('keydown', (e: KeyboardEvent) => { - if (e.code !== 'Space') return; - // Ignore only text entry fields; the range slider must still respond to Space. + const isPlayPause = e.code === 'Space'; + const stepDirection = e.code === 'ArrowLeft' ? -1 : e.code === 'ArrowRight' ? 1 : 0; + if (!isPlayPause && stepDirection === 0) return; const target = e.target; const isTextInput = (target instanceof HTMLInputElement && target.type !== 'range') || - target instanceof HTMLTextAreaElement; + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable); if (isTextInput) return; + // Range slider owns Arrow keys for scrub; only Space passes through to play/pause. + const isRangeSlider = target instanceof HTMLInputElement && target.type === 'range'; + if (isRangeSlider && !isPlayPause) return; const canvas = document.getElementById('pagx-canvas'); if (!canvas || canvas.classList.contains('hidden')) return; e.preventDefault(); - togglePlayback(); + if (isPlayPause) { + togglePlayback(); + } else { + stepFrame(stepDirection); + } }); // Update playback UI periodically. Also fire once on the play -> stop transition so the slider, @@ -1585,6 +1604,12 @@ if (typeof window !== 'undefined') { // Encodes XML, reparses and redraws the view. Shared by the editor's Apply // and Save actions. Returns '' on success, otherwise an error message. + // + // Runs the same UI refresh as loadPAGXData's post-parse step so that an edit which + // changes the canvas dimensions (width/height attrs) or the animation duration (static + // ↔ animated) is fully reflected: canvas backing store is resized, the playback bar + // shows/hides, and the time/frame display picks up the new duration instead of showing + // the previous file's values. const applyXmlToView = (xmlText: string): string => { if (playgroundState.pagxView === null) { return 'PAGXView not initialized'; @@ -1593,6 +1618,7 @@ if (typeof window !== 'undefined') { const data = new TextEncoder().encode(xmlText); playgroundState.pagxView.parsePAGX(data); playgroundState.pagxView.buildLayers(); + refreshUIAfterReparse(); playgroundState.pagxView.draw(); return ''; } catch (e) { diff --git a/playground/pagx-viewer/src/cpp/PAGXView.cpp b/playground/pagx-viewer/src/cpp/PAGXView.cpp index 5f3437d46c..3b9402510b 100644 --- a/playground/pagx-viewer/src/cpp/PAGXView.cpp +++ b/playground/pagx-viewer/src/cpp/PAGXView.cpp @@ -511,13 +511,19 @@ int64_t PAGXView::durationMicros() const { return 0; } -float PAGXView::currentFrameRate() const { +float PAGXView::frameRate() const { if (defaultAnimation != nullptr) { return defaultAnimation->frameRate(); } return 0.0f; } +// Known limitation: seek only repositions the default (top-level) animation. Nested +// auto-playing compositions rendered by `scene` are delta-driven via advanceAndApply() and have +// no absolute-time entry point, so their frame lags behind the main timeline after a scrub: +// the main animation jumps to `micros` while the nested compositions stay wherever their +// accumulated delta left them. Acceptable for the MVP viewer; a future fix would need per-scene +// seek support (or a full scene rebuild) rather than a workaround here. void PAGXView::setCurrentTimeMicros(int64_t micros) { if (defaultAnimation != nullptr) { defaultAnimation->setCurrentTime(micros); diff --git a/playground/pagx-viewer/src/cpp/PAGXView.h b/playground/pagx-viewer/src/cpp/PAGXView.h index 60e1492e4a..e04b2aa061 100644 --- a/playground/pagx-viewer/src/cpp/PAGXView.h +++ b/playground/pagx-viewer/src/cpp/PAGXView.h @@ -85,7 +85,7 @@ class PAGXView { bool isPlaying() const; int64_t currentTimeMicros() const; int64_t durationMicros() const; - float currentFrameRate() const; + float frameRate() const; void setCurrentTimeMicros(int64_t micros); void setLoop(bool loop); bool isLoop() const; diff --git a/playground/pagx-viewer/src/cpp/binding.cpp b/playground/pagx-viewer/src/cpp/binding.cpp index e701895834..c25afe31be 100644 --- a/playground/pagx-viewer/src/cpp/binding.cpp +++ b/playground/pagx-viewer/src/cpp/binding.cpp @@ -57,7 +57,7 @@ EMSCRIPTEN_BINDINGS(PAGXPlayground) { .function("_durationMicros", optional_override([](pagx::PAGXView& self) -> double { return static_cast(self.durationMicros()); })) - .function("_currentFrameRate", &pagx::PAGXView::currentFrameRate) + .function("_frameRate", &pagx::PAGXView::frameRate) .function("_setCurrentTimeMicros", optional_override([](pagx::PAGXView& self, double micros) { self.setCurrentTimeMicros(static_cast(micros)); })) diff --git a/playground/pagx-viewer/src/ts/pagx-view.ts b/playground/pagx-viewer/src/ts/pagx-view.ts index 6412961ff4..573cef3af7 100644 --- a/playground/pagx-viewer/src/ts/pagx-view.ts +++ b/playground/pagx-viewer/src/ts/pagx-view.ts @@ -333,8 +333,8 @@ export class PAGXView { /** * Returns the frame rate of the animation. Returns 0 if no content is loaded. */ - public currentFrameRate(): number { - return this.nativeView._currentFrameRate(); + public frameRate(): number { + return this.nativeView._frameRate(); } /** diff --git a/playground/pagx-viewer/src/ts/types.ts b/playground/pagx-viewer/src/ts/types.ts index 29df55e463..fd7fbd8ce9 100644 --- a/playground/pagx-viewer/src/ts/types.ts +++ b/playground/pagx-viewer/src/ts/types.ts @@ -158,7 +158,7 @@ export interface _PAGXView { /** * Returns the frame rate of the animation. */ - _currentFrameRate(): number; + _frameRate(): number; /** * Sets the current playback time in microseconds.