From fb2b6ea9b6f537b0ad610dad8a8776bf986a5cd3 Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Thu, 5 Mar 2026 20:03:49 -0800 Subject: [PATCH] support `#asseteditor/filename/lineno` - Track selected asset in hash - Highlight selected asset on navigation --- css/ui.css | 9 +++++++ src/ide/ui.ts | 3 +++ src/ide/views/assetdecorations.ts | 21 +++++++++++------ src/ide/views/asseteditor.ts | 39 +++++++++++++++++++++++++++++-- src/ide/views/editors.ts | 4 ++-- src/ide/windows.ts | 3 ++- 6 files changed, 67 insertions(+), 12 deletions(-) diff --git a/css/ui.css b/css/ui.css index 862ffa2c..c3496907 100644 --- a/css/ui.css +++ b/css/ui.css @@ -497,6 +497,15 @@ div.asset_file_header { } div.asset_block { margin-top: 8px; + transition: outline-color 1.5s ease-out; + outline: 2px solid transparent; + background-color: transparent; + +} +div.asset_block.asset_highlight { + outline: 2px solid #99cc99; + border-radius: 4px; + transition: none; } .asset_snip { font-family: "Andale Mono", "Menlo", "Lucida Console", monospace; diff --git a/src/ide/ui.ts b/src/ide/ui.ts index c78f96aa..193da4ce 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -1747,6 +1747,8 @@ function replaceURLState() { function hashToViewIdResolved(hash: string): string | null { if (!hash || hash === '#') return null; var id = decodeURIComponent(hash.substring(1)); + // check for extended asset editor hash (e.g. #asseteditor/filename/startline) + if (id.startsWith('asseteditor/')) return '#asseteditor'; // check if it's a registered tool window (e.g. #asseteditor) if (projectWindows.isWindow('#' + id)) return '#' + id; // otherwise treat as a file path (e.g. hello.asm) @@ -1769,6 +1771,7 @@ function installHashChangeHandler() { function navigateToInitialHash(initialHash: string) { if (initialHash && initialHash !== '#') { const viewId = hashToViewIdResolved(initialHash) || getCurrentMainFilename(); + history.replaceState(null, '', initialHash); projectWindows.createOrShow(viewId); } } diff --git a/src/ide/views/assetdecorations.ts b/src/ide/views/assetdecorations.ts index 52b09111..b709fc66 100644 --- a/src/ide/views/assetdecorations.ts +++ b/src/ide/views/assetdecorations.ts @@ -2,7 +2,7 @@ import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate, WidgetTy // Asset header detection — shows a clickable badge on lines with ;;{json};; or /*{json}*/ class AssetHeaderWidget extends WidgetType { - constructor(readonly header: string, readonly onClick: () => void) { super() } + constructor(readonly header: string, readonly handleClick: (span: HTMLElement) => void) { super() } toDOM() { const span = document.createElement("span"); @@ -12,7 +12,7 @@ class AssetHeaderWidget extends WidgetType { span.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); - this.onClick(); + this.handleClick(span); }); return span; } @@ -23,7 +23,7 @@ class AssetHeaderWidget extends WidgetType { const assetHeaderRegex = /[/;][*;](\{.+?\})[*;][/;]/g; -function buildAssetHeaderDecorations(view: EditorView, onClick: () => void): DecorationSet { +function buildAssetHeaderDecorations(view: EditorView, handleClick: (span: HTMLElement) => void): DecorationSet { const widgets: any[] = []; for (let { from, to } of view.visibleRanges) { const text = view.state.sliceDoc(from, to); @@ -36,7 +36,7 @@ function buildAssetHeaderDecorations(view: EditorView, onClick: () => void): Dec const lineEnd = lineStart + line.length; widgets.push( Decoration.widget({ - widget: new AssetHeaderWidget(m[0], onClick), + widget: new AssetHeaderWidget(m[0], handleClick), side: 1, }).range(lineEnd) ); @@ -47,15 +47,22 @@ function buildAssetHeaderDecorations(view: EditorView, onClick: () => void): Dec return Decoration.set(widgets, true); } -export function createAssetHeaderPlugin(onClick: () => void) { +export function createAssetHeaderPlugin(onClick: (lineNumber: number) => void) { + let currentView: EditorView; + function handleClick(span: HTMLElement) { + const pos = currentView.posAtDOM(span); + onClick(currentView.state.doc.lineAt(pos).number); + } return ViewPlugin.fromClass(class { decorations: DecorationSet; constructor(view: EditorView) { - this.decorations = buildAssetHeaderDecorations(view, onClick); + currentView = view; + this.decorations = buildAssetHeaderDecorations(view, handleClick); } update(update: ViewUpdate) { + currentView = update.view; if (update.docChanged || update.viewportChanged) { - this.decorations = buildAssetHeaderDecorations(update.view, onClick); + this.decorations = buildAssetHeaderDecorations(update.view, handleClick); } } }, { diff --git a/src/ide/views/asseteditor.ts b/src/ide/views/asseteditor.ts index 25edf296..dec4db97 100644 --- a/src/ide/views/asseteditor.ts +++ b/src/ide/views/asseteditor.ts @@ -22,7 +22,6 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { cureditnode: pixed.PixNode; rootnodes: pixed.PixNode[]; deferrednodes: pixed.PixNode[]; - createDiv(parent: HTMLElement) { this.maindiv = newDiv(parent, "vertical-scroll"); return this.maindiv[0]; @@ -134,6 +133,21 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { node = node.left; } this.cureditnode = node; + this.updateHashForSelection(div); + } + + updateHashForSelection(div: JQuery) { + if (typeof window === 'undefined') return; + var block = div ? div.closest('.asset_block[data-fileid]') : null; + var fileid = block && block.attr('data-fileid'); + var startline = block && block.attr('data-startline'); + var hash = '#asseteditor'; + if (fileid && startline) { + hash += '/' + encodeURIComponent(fileid) + '/' + startline; + } + if (window.location.hash !== hash) { + history.replaceState(null, '', hash); + } } scanFileTextForAssets(id: string, data: string) { @@ -330,7 +344,9 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { } else if (typeof data === 'string') { let textfrags = this.scanFileTextForAssets(fileid, data); for (let frag of textfrags) { - const block = $('
').appendTo(this.ensureFileDiv(fileid)); + const block = $('
') + .attr({ 'data-fileid': fileid, 'data-startline': frag.startline }) + .appendTo(this.ensureFileDiv(fileid)); var snip = $('
').appendTo(block); var linenos = $('').appendTo(snip); $('').text(frag.startline).appendTo(linenos); @@ -422,6 +438,7 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { } }); this.deferrednodes = []; + this.scrollToAssetFromHash(); } else { for (var node of this.rootnodes) { node.refreshRight(); @@ -429,6 +446,24 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { } } + scrollToAssetFromHash(): void { + var hash = window.location.hash; + if (!hash || !hash.startsWith('#asseteditor/')) return; + var parts = hash.substring(1).split('/'); // ['asseteditor', ...filename, startline] + var fileid = decodeURIComponent(parts.slice(1, -1).join('/')); + var startline = parts[parts.length - 1]; + if (!fileid || !startline) return; + // defer to allow DOM to settle + setTimeout(() => { + const block = this.maindiv.find(`.asset_block[data-fileid="${fileid}"][data-startline="${startline}"]`); + if (block.length) { + block[0].scrollIntoView({ behavior: "smooth", block: "center" }); + block.addClass('asset_highlight'); + setTimeout(() => block.removeClass('asset_highlight'), 500); + } + }, 0); + } + setVisible?(showing: boolean): void { // TODO: make into toolbar? if (showing) { diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index d4fa9128..6e4e14fd 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -245,8 +245,8 @@ export class SourceEditor implements ProjectView { highlightLines.field, - createAssetHeaderPlugin(() => { - projectWindows.createOrShow('#asseteditor'); + createAssetHeaderPlugin((lineNumber: number) => { + window.location.hash = 'asseteditor/' + encodeURIComponent(this.path) + '/' + lineNumber; }), textTransformFilterCompartment.of([]), diff --git a/src/ide/windows.ts b/src/ide/windows.ts index 7779b929..d6dcee75 100644 --- a/src/ide/windows.ts +++ b/src/ide/windows.ts @@ -83,7 +83,8 @@ export class ProjectWindows { } } else { const hash = id.startsWith('#') ? id : '#' + encodeURIComponent(id); - if (window.location.hash !== hash) { + // don't overwrite an extended hash (e.g. #asseteditor/file/line) with the base hash + if (window.location.hash !== hash && !window.location.hash.startsWith(hash + '/')) { history.replaceState(null, '', hash); } }