From ad5da2d810c327d70f98e6455e2b064ac852c010 Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Wed, 25 Mar 2026 21:54:53 -0700 Subject: [PATCH 01/12] Asset editor flushes pending writes Avoid asset editor seeing stale data or project file writes being lost when a delayed editor read/write competes with a synchronous asset editor read/write --- src/ide/views/asseteditor.ts | 4 +++- src/ide/views/baseviews.ts | 1 + src/ide/views/editors.ts | 8 ++++++++ src/ide/windows.ts | 9 +++++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/ide/views/asseteditor.ts b/src/ide/views/asseteditor.ts index 2c94c83d..1e76c2e6 100644 --- a/src/ide/views/asseteditor.ts +++ b/src/ide/views/asseteditor.ts @@ -477,7 +477,9 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { setVisible?(showing: boolean): void { // TODO: make into toolbar? if (showing) { - // limit undo/redo to since opening this editor + // ensure asset editor is safe to perform synchronous reads/writes + projectWindows.flushAllWindows(); + // limit undo/redo to since opening this asset editor projectWindows.undofiles = []; projectWindows.redofiles = []; if (Mousetrap.bind) { diff --git a/src/ide/views/baseviews.ts b/src/ide/views/baseviews.ts index 27e84264..13f2fa79 100644 --- a/src/ide/views/baseviews.ts +++ b/src/ide/views/baseviews.ts @@ -20,6 +20,7 @@ export interface ProjectView { recreateOnResize?: boolean; undoStep?(): void; redoStep?(): void; + flushChanges?(): void; replaceTextRange?(from: number, to: number, text: string): void; }; diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index cecd5394..41fcc7ab 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -282,6 +282,14 @@ export class SourceEditor implements ProjectView { }, this.refreshDelayMsec); } + flushChanges() { + if (this.updateTimer) { + clearTimeout(this.updateTimer); + this.updateTimer = null; + current_project.updateFile(this.path, this.editor.state.doc.toString()); + } + } + inspectUnderCursor(update: ViewUpdate) { // TODO: handle multi-select const range = update.state.selection.main; diff --git a/src/ide/windows.ts b/src/ide/windows.ts index 07a80037..cffdc140 100644 --- a/src/ide/windows.ts +++ b/src/ide/windows.ts @@ -208,6 +208,15 @@ export class ProjectWindows { bootbox.alert(msg, () => { this.alerting = false; }); } + flushAllWindows() { + for (var fileid in this.id2window) { + var wnd = this.id2window[fileid]; + if (wnd && wnd.flushChanges) { + wnd.flushChanges(); + } + } + } + updateAllOpenWindows(store) { for (var fileid in this.id2window) { var wnd = this.id2window[fileid]; From ff6b587a62aa6d58bc6cfe315673bf729865909a Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Wed, 25 Mar 2026 21:59:33 -0700 Subject: [PATCH 02/12] Prepare undo/redo stack for binary data --- src/ide/views/asseteditor.ts | 4 ++-- src/ide/windows.ts | 36 ++++++++++++++++++++---------------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/src/ide/views/asseteditor.ts b/src/ide/views/asseteditor.ts index 1e76c2e6..5516d165 100644 --- a/src/ide/views/asseteditor.ts +++ b/src/ide/views/asseteditor.ts @@ -480,8 +480,8 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { // ensure asset editor is safe to perform synchronous reads/writes projectWindows.flushAllWindows(); // limit undo/redo to since opening this asset editor - projectWindows.undofiles = []; - projectWindows.redofiles = []; + projectWindows.undoStack = []; + projectWindows.redoStack = []; if (Mousetrap.bind) { Mousetrap.bind('mod+z', (e) => { projectWindows.undoStep(); return false; }); Mousetrap.bind('mod+shift+z', (e) => { projectWindows.redoStep(); return false; }); diff --git a/src/ide/windows.ts b/src/ide/windows.ts index cffdc140..a1968810 100644 --- a/src/ide/windows.ts +++ b/src/ide/windows.ts @@ -8,6 +8,10 @@ import { ProjectView } from "./views/baseviews"; type WindowCreateFunction = (id: string) => ProjectView; type WindowShowFunction = (id: string, view: ProjectView) => void; +interface UndoEntry { + fileid: string; +} + export class ProjectWindows { containerdiv: HTMLElement; project: CodeProject; @@ -19,16 +23,16 @@ export class ProjectWindows { activewnd: ProjectView; activediv: HTMLElement; lasterrors: WorkerError[]; - undofiles: string[]; - redofiles: string[]; + undoStack: UndoEntry[]; + redoStack: UndoEntry[]; titlePrefix: string; alerting: boolean; constructor(containerdiv: HTMLElement, project: CodeProject) { this.containerdiv = containerdiv; this.project = project; - this.undofiles = []; - this.redofiles = []; + this.undoStack = []; + this.redoStack = []; } // TODO: delete windows ever? @@ -158,8 +162,8 @@ export class ProjectWindows { var wnd = this.id2window[fileid]; if (wnd && wnd.setText && typeof data === 'string') { wnd.setText(data); - this.undofiles.push(fileid); - this.redofiles = []; + this.undoStack.push({ fileid }); + this.redoStack = []; } else { this.project.updateFile(fileid, data); } @@ -168,19 +172,19 @@ export class ProjectWindows { replaceTextRange(fileid: string, from: number, to: number, text: string) { var wnd = this.id2window[fileid] || this.create(fileid); wnd.replaceTextRange(from, to, text); - this.undofiles.push(fileid); - this.redofiles = []; + this.undoStack.push({ fileid }); + this.redoStack = []; } undoStep() { - var fileid = this.undofiles.pop(); - var wnd = this.id2window[fileid]; + var entry = this.undoStack.pop(); + var wnd = entry && this.id2window[entry.fileid]; if (wnd && wnd.undoStep) { wnd.undoStep(); if (wnd.getValue) { - this.project.updateFile(fileid, wnd.getValue()); + this.project.updateFile(entry.fileid, wnd.getValue()); } - this.redofiles.push(fileid); + this.redoStack.push({ fileid: entry.fileid }); this.refresh(false); } else { this.showAlert("No more steps to undo."); @@ -188,14 +192,14 @@ export class ProjectWindows { } redoStep() { - var fileid = this.redofiles.pop(); - var wnd = this.id2window[fileid]; + var entry = this.redoStack.pop(); + var wnd = entry && this.id2window[entry.fileid]; if (wnd && wnd.redoStep) { wnd.redoStep(); if (wnd.getValue) { - this.project.updateFile(fileid, wnd.getValue()); + this.project.updateFile(entry.fileid, wnd.getValue()); } - this.undofiles.push(fileid); + this.undoStack.push({ fileid: entry.fileid }); this.refresh(false); } else { this.showAlert("No more steps to redo."); From 9487968e453ec3650696795a8bebacfda3035c78 Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Wed, 25 Mar 2026 22:04:36 -0700 Subject: [PATCH 03/12] implement asset editor binary file undo/redo --- src/ide/windows.ts | 79 +++++++++++++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 25 deletions(-) diff --git a/src/ide/windows.ts b/src/ide/windows.ts index a1968810..9948edab 100644 --- a/src/ide/windows.ts +++ b/src/ide/windows.ts @@ -10,6 +10,7 @@ type WindowShowFunction = (id: string, view: ProjectView) => void; interface UndoEntry { fileid: string; + data?: Uint8Array; } export class ProjectWindows { @@ -158,15 +159,21 @@ export class ProjectWindows { } updateFile(fileid: string, data: FileData) { - // is there an editor? if so, use it - var wnd = this.id2window[fileid]; - if (wnd && wnd.setText && typeof data === 'string') { - wnd.setText(data); - this.undoStack.push({ fileid }); - this.redoStack = []; - } else { + if (data instanceof Uint8Array) { + var prev = this.project.getFile(fileid); + this.undoStack.push({ fileid, data: prev instanceof Uint8Array ? new Uint8Array(prev) : undefined }); this.project.updateFile(fileid, data); + } else { + var wnd = this.id2window[fileid]; + if (wnd && wnd.setText && typeof data === 'string') { + wnd.setText(data); + this.undoStack.push({ fileid }); + } else { + this.project.updateFile(fileid, data); + return; + } } + this.redoStack = []; } replaceTextRange(fileid: string, from: number, to: number, text: string) { @@ -178,32 +185,54 @@ export class ProjectWindows { undoStep() { var entry = this.undoStack.pop(); - var wnd = entry && this.id2window[entry.fileid]; - if (wnd && wnd.undoStep) { - wnd.undoStep(); - if (wnd.getValue) { - this.project.updateFile(entry.fileid, wnd.getValue()); - } - this.redoStack.push({ fileid: entry.fileid }); - this.refresh(false); - } else { + if (!entry) { this.showAlert("No more steps to undo."); + return; + } + if (entry.data) { + var current = this.project.getFile(entry.fileid); + this.redoStack.push({ fileid: entry.fileid, data: current instanceof Uint8Array ? new Uint8Array(current) : undefined }); + this.project.updateFile(entry.fileid, entry.data); + } else { + var wnd = this.id2window[entry.fileid]; + if (wnd && wnd.undoStep) { + wnd.undoStep(); + if (wnd.getValue) { + this.project.updateFile(entry.fileid, wnd.getValue()); + } + this.redoStack.push({ fileid: entry.fileid }); + } else { + this.showAlert("No more steps to undo."); + return; + } } + this.refresh(false); } redoStep() { var entry = this.redoStack.pop(); - var wnd = entry && this.id2window[entry.fileid]; - if (wnd && wnd.redoStep) { - wnd.redoStep(); - if (wnd.getValue) { - this.project.updateFile(entry.fileid, wnd.getValue()); - } - this.undoStack.push({ fileid: entry.fileid }); - this.refresh(false); - } else { + if (!entry) { this.showAlert("No more steps to redo."); + return; + } + if (entry.data) { + var current = this.project.getFile(entry.fileid); + this.undoStack.push({ fileid: entry.fileid, data: current instanceof Uint8Array ? new Uint8Array(current) : undefined }); + this.project.updateFile(entry.fileid, entry.data); + } else { + var wnd = this.id2window[entry.fileid]; + if (wnd && wnd.redoStep) { + wnd.redoStep(); + if (wnd.getValue) { + this.project.updateFile(entry.fileid, wnd.getValue()); + } + this.undoStack.push({ fileid: entry.fileid }); + } else { + this.showAlert("No more steps to redo."); + return; + } } + this.refresh(false); } showAlert(msg: string) { From a82717d6fdb5f7b33bda5ea6e3e6ba8502adbbef Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Wed, 25 Mar 2026 22:21:42 -0700 Subject: [PATCH 04/12] Fix asset editor `hex 012345...` source editing --- src/ide/pixeleditor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ide/pixeleditor.ts b/src/ide/pixeleditor.ts index 2e98f828..c3b9a021 100644 --- a/src/ide/pixeleditor.ts +++ b/src/ide/pixeleditor.ts @@ -97,6 +97,8 @@ export function parseHexWords(s: string): number[] { } export function replaceHexWords(s: string, words: UintArray, bpw: number): string { + // convert 'hex ...' format to 0x prefixed values for regex matching + s = convertToHexStatements(s); var result = ""; var m; var li = 0; From 9195bfa42a746420b315f89056c5b278a71de619 Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Thu, 26 Mar 2026 20:22:52 -0700 Subject: [PATCH 05/12] support binary file revert to original --- src/ide/ui.ts | 28 ++++++++++++++++++---------- src/ide/views/baseviews.ts | 1 + 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/ide/ui.ts b/src/ide/ui.ts index 3e016724..9993db09 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -493,7 +493,7 @@ async function getSkeletonFile(fileid: string): Promise { try { return await $.get("presets/" + getBasePlatform(platform_id) + "/skeleton." + ext, 'text'); } catch (e) { - console.log(e+""); + console.log(e + ""); return null; } } @@ -707,19 +707,27 @@ export function getCurrentEditorFilename(): string { function _revertFile(e) { var wnd = projectWindows.getActive(); - if (wnd && wnd.setText) { - var fn = projectWindows.getActiveID(); - $.get("presets/" + getBasePlatform(platform_id) + "/" + fn, (text) => { + var fn = projectWindows.getActiveID(); + var isBinary = wnd && wnd.setData && !wnd.setText; + if (wnd && (wnd.setText || wnd.setData)) { + var url = "presets/" + getBasePlatform(platform_id) + "/" + fn; + getWithBinary(url, (data) => { + if (data == null) { + if (repo_id) alertError("Can only revert built-in examples. If you want to revert all files, You can pull from the repository."); + else alertError("Can only revert built-in examples."); + return; + } bootbox.confirm("Reset '" + DOMPurify.sanitize(fn) + "' to default?", (ok) => { if (ok) { - wnd.setText(text); + if (isBinary) { + wnd.setData(data as Uint8Array); + current_project.updateFile(fn, data as Uint8Array); + } else { + wnd.setText(data as string); + } } }); - }, 'text') - .fail(() => { - if (repo_id) alertError("Can only revert built-in examples. If you want to revert all files, You can pull from the repository."); - else alertError("Can only revert built-in examples."); - }); + }, isBinary ? 'arraybuffer' : 'text'); } else { alertError("Cannot revert the active window. Please choose a text file."); } diff --git a/src/ide/views/baseviews.ts b/src/ide/views/baseviews.ts index 13f2fa79..bc7a57af 100644 --- a/src/ide/views/baseviews.ts +++ b/src/ide/views/baseviews.ts @@ -10,6 +10,7 @@ export interface ProjectView { getPath?(): string; getValue?(): string; setText?(text: string): void; + setData?(data: any): void; insertLinesBefore?(text: string): void; getCursorPC?(): number; getSourceFile?(): SourceFile; From 9be2b86af75236d52d62f9727a9f748bc46eb7ce Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sat, 11 Apr 2026 17:27:29 -0700 Subject: [PATCH 06/12] Asset editor linenos style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adjust asset editor linenos style and match '↗' call out from source code editor. --- css/ui.css | 3 ++- src/ide/views/asseteditor.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/css/ui.css b/css/ui.css index 31cf8769..3b499b40 100644 --- a/css/ui.css +++ b/css/ui.css @@ -513,7 +513,7 @@ div.asset_block.asset_highlight { font-family: "Andale Mono", "Menlo", "Lucida Console", monospace; font-weight: bold; color: #c1c1b0; - background-color: #555; + background-color: #4a4a4a; border-radius: 8px; padding-left: 1em; } @@ -523,6 +523,7 @@ div.asset_block.asset_highlight { cursor: pointer; padding: 1px 4px; border-radius: 4px; + background: rgba(153, 204, 153, 0.15); } .asset_linenos:hover { text-decoration: underline; diff --git a/src/ide/views/asseteditor.ts b/src/ide/views/asseteditor.ts index 5516d165..23dbab3f 100644 --- a/src/ide/views/asseteditor.ts +++ b/src/ide/views/asseteditor.ts @@ -351,6 +351,7 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { .appendTo(this.ensureFileDiv(fileid)); var snip = $('
').appendTo(block); var linenos = $('').appendTo(snip); + $('').text('↗ ').appendTo(linenos); $('').text(frag.startline).appendTo(linenos); linenos.append('-'); $('').text(frag.endline).appendTo(linenos); From d386371f4cc13bb3d9697fb2458a3019df684e5b Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sat, 11 Apr 2026 18:54:39 -0700 Subject: [PATCH 07/12] asset editor cursor:crosshair --- css/ui.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/css/ui.css b/css/ui.css index 3b499b40..fcb18e18 100644 --- a/css/ui.css +++ b/css/ui.css @@ -267,6 +267,7 @@ div.emuspacer { border-color:#888; } canvas.pixelated { + cursor: crosshair; image-rendering: optimizeSpeed; /* Older versions of FF */ image-rendering: -moz-crisp-edges; /* FF 6.0+ */ image-rendering: -webkit-optimize-contrast; /* Safari */ @@ -573,6 +574,7 @@ div.asset_grid span { .asset_cell { padding: 0px; border: 1px solid black; + cursor: crosshair; } .asset_cell:hover { border: 1px solid white; From 90838c3c1f3197b3c784db407ef9a592344d9790 Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sat, 11 Apr 2026 20:48:25 -0700 Subject: [PATCH 08/12] Asset editing up to 800x1000 canvas Update asset editor scaling logic - Max dimensions: 800 x 1000 - Max scale: 16x Fixes asset editor canvas size being tool small for larger assets. --- src/ide/pixeleditor.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/ide/pixeleditor.ts b/src/ide/pixeleditor.ts index c3b9a021..cf1637b9 100644 --- a/src/ide/pixeleditor.ts +++ b/src/ide/pixeleditor.ts @@ -6,6 +6,10 @@ import Mousetrap = require('mousetrap'); export type UintArray = number[] | Uint8Array | Uint16Array | Uint32Array; //{[i:number]:number}; +const MAX_SIZE_X = 800; +const MAX_SIZE_Y = 1000; +const MAX_SCALE = 16; + // TODO: separate view/controller export interface EditorContext { setCurrentEditor(div: JQuery, editing: JQuery, node: PixNode): void; @@ -921,9 +925,13 @@ export class CharmapEditor extends PixNode { chooser.width = this.fmt.w || 1; chooser.height = this.fmt.h || 1; chooser.recreate(agrid, (index, viewer) => { - var yscale = Math.ceil(256 / this.fmt.w); // TODO: variable scale? - var xscale = yscale * (this.fmt.aspect || 1.0); - var editview = this.createEditor(aeditor, viewer, xscale, yscale); + // TODO: variable scale? + const aspect = this.fmt.aspect || 1.0; + const yscale = Math.min(MAX_SCALE, + MAX_SIZE_X / this.fmt.w * aspect, + MAX_SIZE_Y / this.fmt.h); + const xscale = yscale * aspect; + this.createEditor(aeditor, viewer, xscale, yscale); this.context.setCurrentEditor(aeditor, $(viewer.canvas), this); this.rgbimgs[index] = viewer.rgbdata; }); @@ -953,9 +961,6 @@ export class CharmapEditor extends PixNode { im.updateImage(); var w = viewer.width * xscale; var h = viewer.height * yscale; - while (w > 500 || h > 500) { - w /= 2; h /= 2; - } im.canvas.style.width = w + 'px'; // TODO im.canvas.style.height = h + 'px'; // TODO im.makeEditable(this, aeditor, this.left.palette); From b2d99109cd2078fa97594ce6494a71b81122fd1b Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sun, 12 Apr 2026 10:46:37 -0700 Subject: [PATCH 09/12] asset_dual `flex-wrap: wrap` For wide assets, editable canvas is display below the selectable asset. --- css/ui.css | 1 + 1 file changed, 1 insertion(+) diff --git a/css/ui.css b/css/ui.css index fcb18e18..9b5017d9 100644 --- a/css/ui.css +++ b/css/ui.css @@ -590,6 +590,7 @@ td.asset_editable { div.asset_dual { display: flex; align-items: flex-start; + flex-wrap: wrap; } div.asset_dual table { border-spacing: 10px; From 6e36ea53a259aca2b94659cdf0c3bd562691db2f Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sun, 12 Apr 2026 13:46:13 -0700 Subject: [PATCH 10/12] Fix undo/redo when range len changes Refactor pixeleditor.ts to use ranges instead of brittle, manually tracked start and end offset which weren't surviving undo/redo when rewritten words resulted in changed range len. --- src/ide/pixeleditor.ts | 36 +++++++++------------ src/ide/views/asseteditor.ts | 8 +++-- src/ide/views/baseviews.ts | 5 ++- src/ide/views/editors.ts | 62 ++++++++++++++++++++++++++++++++++-- src/ide/windows.ts | 32 ++++++++++++++++--- test/cli/testpixelconvert.js | 33 ++++++++++++++++--- 6 files changed, 141 insertions(+), 35 deletions(-) diff --git a/src/ide/pixeleditor.ts b/src/ide/pixeleditor.ts index cf1637b9..3adf7beb 100644 --- a/src/ide/pixeleditor.ts +++ b/src/ide/pixeleditor.ts @@ -1,7 +1,7 @@ -import { hex, tobin, rgb2bgr, rle_unpack } from "../common/util"; -import { ProjectWindows } from "./windows"; +import { hex, rgb2bgr, rle_unpack, tobin } from "../common/util"; import { Toolbar } from "./toolbar"; +import { ProjectWindows } from "./windows"; import Mousetrap = require('mousetrap'); export type UintArray = number[] | Uint8Array | Uint16Array | Uint32Array; //{[i:number]:number}; @@ -496,43 +496,37 @@ export class FileDataNode extends CodeProjectDataNode { } export class TextDataNode extends CodeProjectDataNode { - text: string; - start: number; - end: number; bpw: number; + rangeId: string; + + private static nextRangeId = 0; - // TODO: what if file size/layout changes? constructor(project: ProjectWindows, fileid: string, label: string, start: number, end: number, bpw?: number) { super(); this.project = project; this.fileid = fileid; this.label = label; - this.start = start; - this.end = end; this.bpw = bpw || 8; + this.rangeId = `asset_${TextDataNode.nextRangeId++}`; + this.project.setAssetRange(this.fileid, this.rangeId, start, end); } + updateLeft() { if (this.right.words.length != this.words.length) throw Error("Cannot put " + this.right.words.length + " image bytes into array of " + this.words.length + " bytes"); this.words = this.right.words; - // TODO: reload editors? - var datastr = this.text.substring(this.start, this.end); + var datastr = this.project.getAssetText(this.fileid, this.rangeId); datastr = replaceHexWords(datastr, this.words, this.bpw); - if (this.project) { - this.project.replaceTextRange(this.fileid, this.start, this.end, datastr); - } - this.text = this.text.substring(0, this.start) + datastr + this.text.substring(this.end); - this.end = this.start + datastr.length; + // CM6 state field automatically remaps all tracked ranges. + this.project.replaceAssetText(this.fileid, this.rangeId, datastr); return true; } + updateRight() { - if (this.project) { - this.text = this.project.project.getFile(this.fileid) as string; - } - var datastr = this.text.substring(this.start, this.end); - datastr = convertToHexStatements(datastr); // TODO? + var datastr = this.project.getAssetText(this.fileid, this.rangeId); + datastr = convertToHexStatements(datastr); var words = parseHexWords(datastr); - this.words = words; //new Uint8Array(words); // TODO: 16/32? + this.words = words; return true; } } diff --git a/src/ide/views/asseteditor.ts b/src/ide/views/asseteditor.ts index 23dbab3f..54d2f104 100644 --- a/src/ide/views/asseteditor.ts +++ b/src/ide/views/asseteditor.ts @@ -1,9 +1,9 @@ -import { newDiv, ProjectView } from "./baseviews"; -import { platform_id, current_project, projectWindows } from "../ui"; +import { hex, rgb2bgr, safeident } from "../../common/util"; import { FileData } from "../../common/workertypes"; -import { hex, safeident, rgb2bgr } from "../../common/util"; import * as pixed from "../pixeleditor"; +import { current_project, platform_id, projectWindows } from "../ui"; +import { newDiv, ProjectView } from "./baseviews"; import Mousetrap = require('mousetrap'); function getLineNumber(data: string, offset: number): number { @@ -425,6 +425,8 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext { this.clearAssets(); current_project.iterateFiles((fileid, data) => { try { + // Clear stale tracked ranges before re-scanning this file. + projectWindows.clearAssetRanges(fileid); var nassets = this.refreshAssetsInFile(fileid, data); } catch (e) { console.log(e); diff --git a/src/ide/views/baseviews.ts b/src/ide/views/baseviews.ts index bc7a57af..cef47c4f 100644 --- a/src/ide/views/baseviews.ts +++ b/src/ide/views/baseviews.ts @@ -22,7 +22,10 @@ export interface ProjectView { undoStep?(): void; redoStep?(): void; flushChanges?(): void; - replaceTextRange?(from: number, to: number, text: string): void; + setAssetRange?(id: string, from: number, to: number): void; + getAssetText?(id: string): string | null; + replaceAssetText?(id: string, text: string): void; + clearAssetRanges?(): void; }; // detect mobile (https://stackoverflow.com/questions/3514784/what-is-the-best-way-to-detect-a-mobile-device) diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index 41fcc7ab..a7f23bf1 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -3,7 +3,7 @@ import { cpp } from "@codemirror/lang-cpp"; import { markdown } from "@codemirror/lang-markdown"; import { bracketMatching, foldGutter, indentOnInput, indentService, indentUnit } from "@codemirror/language"; import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search"; -import { EditorState, Extension } from "@codemirror/state"; +import { EditorState, Extension, StateEffect, StateField } from "@codemirror/state"; import { crosshairCursor, drawSelection, dropCursor, EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, rectangularSelection, ViewUpdate } from "@codemirror/view"; import { CodeAnalyzer } from "../../common/analysis"; import { hex, rpad } from "../../common/util"; @@ -31,6 +31,39 @@ import { currentPc, errorMessages, errorSpans, highlightLines, showValue } from // look ahead this many bytes when finding source lines for a PC export const PC_LINE_LOOKAHEAD = 64; +// Asset range tracking. Positions are automatically remapped through +// document changes (edits, undo, redo) by CodeMirror's transaction system. +const setAssetRangesEffect = StateEffect.define<{id: string, from: number, to: number}[]>(); +const clearAssetRangesEffect = StateEffect.define(); + +const assetRangesField = StateField.define>({ + create() { return new Map(); }, + update(ranges, tr) { + let result = ranges; + for (let e of tr.effects) { + if (e.is(clearAssetRangesEffect)) { + result = new Map(); + } else if (e.is(setAssetRangesEffect)) { + if (result === ranges) result = new Map(ranges); + for (let r of e.value) { + result.set(r.id, { from: r.from, to: r.to }); + } + } + } + if (!tr.changes.empty) { + const mapped = new Map(); + for (const [id, r] of result) { + mapped.set(id, { + from: tr.changes.mapPos(r.from, -1), + to: tr.changes.mapPos(r.to, 1) + }); + } + return mapped; + } + return result; + } +}); + const MAX_ERRORS = 200; const MODEDEFS = { @@ -249,6 +282,8 @@ export class SourceEditor implements ProjectView { highlightLines.field, + assetRangesField, + createAssetHeaderPlugin((lineNumber: number) => { window.location.hash = 'asseteditor/' + encodeURIComponent(this.path) + '/' + lineNumber; }), @@ -323,7 +358,6 @@ export class SourceEditor implements ProjectView { replaceTextRange(from: number, to: number, text: string) { const fromline = this.editor.state.doc.lineAt(from).number; - const toline = this.editor.state.doc.lineAt(to).number; this.editor.dispatch({ changes: { from, to, insert: text }, annotations: isolateHistory.of("full"), @@ -334,6 +368,30 @@ export class SourceEditor implements ProjectView { }); } + setAssetRange(id: string, from: number, to: number) { + this.editor.dispatch({ + effects: setAssetRangesEffect.of([{ id, from, to }]) + }); + } + + getAssetText(id: string): string | null { + var range = this.editor.state.field(assetRangesField).get(id); + if (!range) return null; + return this.editor.state.doc.sliceString(range.from, range.to); + } + + replaceAssetText(id: string, text: string) { + var range = this.editor.state.field(assetRangesField).get(id); + if (!range) return; + this.replaceTextRange(range.from, range.to, text); + } + + clearAssetRanges() { + this.editor.dispatch({ + effects: clearAssetRangesEffect.of(undefined) + }); + } + insertLinesBefore(text: string) { const pos = this.editor.state.selection.main.from; const lineNum = this.editor.state.doc.lineAt(pos).number; diff --git a/src/ide/windows.ts b/src/ide/windows.ts index 9948edab..414d5cc9 100644 --- a/src/ide/windows.ts +++ b/src/ide/windows.ts @@ -1,8 +1,8 @@ import $ = require("jquery"); +import { getFilenameForPath, getFilenamePrefix } from "../common/util"; +import { FileData, WorkerError } from "../common/workertypes"; import { CodeProject } from "./project"; -import { WorkerError, FileData } from "../common/workertypes"; -import { getFilenamePrefix, getFilenameForPath } from "../common/util"; import { ProjectView } from "./views/baseviews"; type WindowCreateFunction = (id: string) => ProjectView; @@ -176,13 +176,37 @@ export class ProjectWindows { this.redoStack = []; } - replaceTextRange(fileid: string, from: number, to: number, text: string) { + setAssetRange(fileid: string, id: string, from: number, to: number) { var wnd = this.id2window[fileid] || this.create(fileid); - wnd.replaceTextRange(from, to, text); + if (wnd.setAssetRange) { + wnd.setAssetRange(id, from, to); + } + } + + getAssetText(fileid: string, id: string): string | null { + var wnd = this.id2window[fileid] || this.create(fileid); + if (wnd.getAssetText) { + return wnd.getAssetText(id); + } + return null; + } + + replaceAssetText(fileid: string, id: string, text: string) { + var wnd = this.id2window[fileid] || this.create(fileid); + if (wnd.replaceAssetText) { + wnd.replaceAssetText(id, text); + } this.undoStack.push({ fileid }); this.redoStack = []; } + clearAssetRanges(fileid: string) { + var wnd = this.id2window[fileid]; + if (wnd && wnd.clearAssetRanges) { + wnd.clearAssetRanges(); + } + } + undoStep() { var entry = this.undoStack.pop(); if (!entry) { diff --git a/test/cli/testpixelconvert.js b/test/cli/testpixelconvert.js index c7460bd5..b3c2b7b4 100644 --- a/test/cli/testpixelconvert.js +++ b/test/cli/testpixelconvert.js @@ -11,6 +11,33 @@ function dumbEqual(a,b) { return assert.deepEqual(a,b); } +// Minimal mock that satisfies TextDataNode's project interface. +function mockProject(text) { + var fileText = text; + var ranges = {}; + return { + setAssetRange: function(fileid, id, from, to) { ranges[id] = {from: from, to: to}; }, + getAssetText: function(fileid, id) { + var r = ranges[id]; + return r ? fileText.substring(r.from, r.to) : null; + }, + replaceAssetText: function(fileid, id, newText) { + var r = ranges[id]; + if (!r) return; + var delta = newText.length - (r.to - r.from); + fileText = fileText.substring(0, r.from) + newText + fileText.substring(r.to); + var replacedFrom = r.from, replacedTo = r.to; + r.to = r.from + newText.length; + for (var otherId in ranges) { + if (otherId === id) continue; + var other = ranges[otherId]; + if (other.from >= replacedTo) other.from += delta; + if (other.to >= replacedTo) other.to += delta; + } + } + }; +} + describe('Pixel editor', function() { it('Should decode', function() { @@ -18,8 +45,7 @@ describe('Pixel editor', function() { var palfmt = {pal:332,n:16}; var paldatastr = " 0x00, 0x03, 0x19, 0x50, 0x52, 0x07, 0x1f, 0x37, 0xe0, 0xa4, 0xfd, 0xff, 0x38, 0x70, 0x7f, 0x7f, "; // test two entries the same - var node4 = new pixed.TextDataNode(null, null, null, 0, paldatastr.length); - node4.text = paldatastr; + var node4 = new pixed.TextDataNode(mockProject(paldatastr), 'test', 'test', 0, paldatastr.length); var node5 = new pixed.PaletteFormatToRGB(palfmt); node4.addRight(node5); node4.refreshRight(); @@ -51,8 +77,7 @@ describe('Pixel editor', function() { }; var datastr = "1,2, 0x00,0x00,0xef,0xef,0xe0,0x00,0x00, 0x00,0xee,0xee,0xfe,0xee,0xe0,0x00, 0x0e,0xed,0xef,0xef,0xed,0xee,0x00, 0x0e,0xee,0xdd,0xdd,0xde,0xee,0x00, 0x0e,0xee,0xed,0xde,0xee,0xee,0x00, 0x00,0xee,0xee,0xde,0xee,0xe0,0x00, 0x00,0xee,0xee,0xde,0xee,0xe0,0x00, 0x00,0x00,0xed,0xdd,0xe0,0x00,0x0d, 0xdd,0xdd,0xee,0xee,0xed,0xdd,0xd0, 0x0d,0xee,0xee,0xee,0xee,0xee,0x00, 0x0e,0xe0,0xee,0xee,0xe0,0xee,0x00, 0x0e,0xe0,0xee,0xee,0xe0,0xee,0x00, 0x0e,0xe0,0xdd,0xdd,0xd0,0xde,0x00, 0x0d,0x00,0xee,0x0e,0xe0,0x0d,0x00, 0x00,0x00,0xed,0x0e,0xe0,0x00,0x00, 0x00,0x0d,0xdd,0x0d,0xdd,0x00,0x18,"; - var node1 = new pixed.TextDataNode(null, null, null, 0, datastr.length); - node1.text = datastr; + var node1 = new pixed.TextDataNode(mockProject(datastr), 'test', 'test', 0, datastr.length); var node2 = new pixed.Mapper(fmt); node1.addRight(node2); var node3 = new pixed.Palettizer(ctx, fmt); From b8eb873c03a202f6bd63e0d7c5d041e27906e05a Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sun, 12 Apr 2026 14:45:29 -0700 Subject: [PATCH 11/12] Fix undo/redo scrollIntoView for asset edits Improve experience in the source editor for undo/redo of edits made in the asset editor. --- src/ide/views/editors.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ide/views/editors.ts b/src/ide/views/editors.ts index a7f23bf1..e08a9d33 100644 --- a/src/ide/views/editors.ts +++ b/src/ide/views/editors.ts @@ -357,13 +357,13 @@ export class SourceEditor implements ProjectView { } replaceTextRange(from: number, to: number, text: string) { - const fromline = this.editor.state.doc.lineAt(from).number; + const lineStart = this.editor.state.doc.lineAt(from).from; this.editor.dispatch({ changes: { from, to, insert: text }, annotations: isolateHistory.of("full"), - selection: { anchor: from, head: to }, + selection: { anchor: from + text.length, head: from }, effects: [ - EditorView.scrollIntoView(this.editor.state.doc.line(fromline).from, { y: "start", yMargin: 100/*pixels*/ }), + EditorView.scrollIntoView(lineStart, { y: "start", yMargin: 100/*pixels*/ }), ] }); } From 06ebd1554f967d1392169836c1ea4e1741414907 Mon Sep 17 00:00:00 2001 From: Fred Sauer Date: Sun, 12 Apr 2026 21:25:06 -0700 Subject: [PATCH 12/12] Improve palette button highlight Invert border so all colors have visible selection, not just those that contrast with white. --- css/ui.css | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/css/ui.css b/css/ui.css index 9b5017d9..d98b9d65 100644 --- a/css/ui.css +++ b/css/ui.css @@ -276,14 +276,23 @@ canvas.pixelated { -ms-interpolation-mode: nearest-neighbor; /* IE */ } .palbtn { - width:2em; - height:2em; - border-style:none; -} -.palbtn.selected { - border-width:2px; - border-color:white; - border-style:dotted; + width: 2em; + height: 2em; + border-style: none; + position: relative; + border: none; +} +.palbtn.selected::after { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: 2px dotted #888; + border-radius: inherit; + mix-blend-mode: difference; + pointer-events: none; } #javatari-screen canvas { box-sizing: content-box;