Skip to content

Commit 0a0e26d

Browse files
committed
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.
1 parent ab9108f commit 0a0e26d

6 files changed

Lines changed: 141 additions & 35 deletions

File tree

src/ide/pixeleditor.ts

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

2-
import { hex, tobin, rgb2bgr, rle_unpack } from "../common/util";
3-
import { ProjectWindows } from "./windows";
2+
import { hex, rgb2bgr, rle_unpack, tobin } from "../common/util";
43
import { Toolbar } from "./toolbar";
4+
import { ProjectWindows } from "./windows";
55
import Mousetrap = require('mousetrap');
66

77
export type UintArray = number[] | Uint8Array | Uint16Array | Uint32Array; //{[i:number]:number};
@@ -496,43 +496,37 @@ export class FileDataNode extends CodeProjectDataNode {
496496
}
497497

498498
export class TextDataNode extends CodeProjectDataNode {
499-
text: string;
500-
start: number;
501-
end: number;
502499
bpw: number;
500+
rangeId: string;
501+
502+
private static nextRangeId = 0;
503503

504-
// TODO: what if file size/layout changes?
505504
constructor(project: ProjectWindows, fileid: string, label: string, start: number, end: number, bpw?: number) {
506505
super();
507506
this.project = project;
508507
this.fileid = fileid;
509508
this.label = label;
510-
this.start = start;
511-
this.end = end;
512509
this.bpw = bpw || 8;
510+
this.rangeId = `asset_${TextDataNode.nextRangeId++}`;
511+
this.project.setAssetRange(this.fileid, this.rangeId, start, end);
513512
}
513+
514514
updateLeft() {
515515
if (this.right.words.length != this.words.length)
516516
throw Error("Cannot put " + this.right.words.length + " image bytes into array of " + this.words.length + " bytes");
517517
this.words = this.right.words;
518-
// TODO: reload editors?
519-
var datastr = this.text.substring(this.start, this.end);
518+
var datastr = this.project.getAssetText(this.fileid, this.rangeId);
520519
datastr = replaceHexWords(datastr, this.words, this.bpw);
521-
if (this.project) {
522-
this.project.replaceTextRange(this.fileid, this.start, this.end, datastr);
523-
}
524-
this.text = this.text.substring(0, this.start) + datastr + this.text.substring(this.end);
525-
this.end = this.start + datastr.length;
520+
// CM6 state field automatically remaps all tracked ranges.
521+
this.project.replaceAssetText(this.fileid, this.rangeId, datastr);
526522
return true;
527523
}
524+
528525
updateRight() {
529-
if (this.project) {
530-
this.text = this.project.project.getFile(this.fileid) as string;
531-
}
532-
var datastr = this.text.substring(this.start, this.end);
533-
datastr = convertToHexStatements(datastr); // TODO?
526+
var datastr = this.project.getAssetText(this.fileid, this.rangeId);
527+
datastr = convertToHexStatements(datastr);
534528
var words = parseHexWords(datastr);
535-
this.words = words; //new Uint8Array(words); // TODO: 16/32?
529+
this.words = words;
536530
return true;
537531
}
538532
}

src/ide/views/asseteditor.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11

2-
import { newDiv, ProjectView } from "./baseviews";
3-
import { platform_id, current_project, projectWindows } from "../ui";
2+
import { hex, rgb2bgr, safeident } from "../../common/util";
43
import { FileData } from "../../common/workertypes";
5-
import { hex, safeident, rgb2bgr } from "../../common/util";
64
import * as pixed from "../pixeleditor";
5+
import { current_project, platform_id, projectWindows } from "../ui";
6+
import { newDiv, ProjectView } from "./baseviews";
77
import Mousetrap = require('mousetrap');
88

99
function getLineNumber(data: string, offset: number): number {
@@ -425,6 +425,8 @@ export class AssetEditorView implements ProjectView, pixed.EditorContext {
425425
this.clearAssets();
426426
current_project.iterateFiles((fileid, data) => {
427427
try {
428+
// Clear stale tracked ranges before re-scanning this file.
429+
projectWindows.clearAssetRanges(fileid);
428430
var nassets = this.refreshAssetsInFile(fileid, data);
429431
} catch (e) {
430432
console.log(e);

src/ide/views/baseviews.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,10 @@ export interface ProjectView {
2222
undoStep?(): void;
2323
redoStep?(): void;
2424
flushChanges?(): void;
25-
replaceTextRange?(from: number, to: number, text: string): void;
25+
setAssetRange?(id: string, from: number, to: number): void;
26+
getAssetText?(id: string): string | null;
27+
replaceAssetText?(id: string, text: string): void;
28+
clearAssetRanges?(): void;
2629
};
2730

2831
// detect mobile (https://stackoverflow.com/questions/3514784/what-is-the-best-way-to-detect-a-mobile-device)

src/ide/views/editors.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { cpp } from "@codemirror/lang-cpp";
33
import { markdown } from "@codemirror/lang-markdown";
44
import { bracketMatching, foldGutter, indentOnInput, indentService, indentUnit } from "@codemirror/language";
55
import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search";
6-
import { EditorState, Extension } from "@codemirror/state";
6+
import { EditorState, Extension, StateEffect, StateField } from "@codemirror/state";
77
import { crosshairCursor, drawSelection, dropCursor, EditorView, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers, rectangularSelection, ViewUpdate } from "@codemirror/view";
88
import { CodeAnalyzer } from "../../common/analysis";
99
import { hex, rpad } from "../../common/util";
@@ -31,6 +31,39 @@ import { currentPc, errorMessages, errorSpans, highlightLines, showValue } from
3131
// look ahead this many bytes when finding source lines for a PC
3232
export const PC_LINE_LOOKAHEAD = 64;
3333

34+
// Asset range tracking. Positions are automatically remapped through
35+
// document changes (edits, undo, redo) by CodeMirror's transaction system.
36+
const setAssetRangesEffect = StateEffect.define<{id: string, from: number, to: number}[]>();
37+
const clearAssetRangesEffect = StateEffect.define<void>();
38+
39+
const assetRangesField = StateField.define<Map<string, {from: number, to: number}>>({
40+
create() { return new Map(); },
41+
update(ranges, tr) {
42+
let result = ranges;
43+
for (let e of tr.effects) {
44+
if (e.is(clearAssetRangesEffect)) {
45+
result = new Map();
46+
} else if (e.is(setAssetRangesEffect)) {
47+
if (result === ranges) result = new Map(ranges);
48+
for (let r of e.value) {
49+
result.set(r.id, { from: r.from, to: r.to });
50+
}
51+
}
52+
}
53+
if (!tr.changes.empty) {
54+
const mapped = new Map<string, {from: number, to: number}>();
55+
for (const [id, r] of result) {
56+
mapped.set(id, {
57+
from: tr.changes.mapPos(r.from, -1),
58+
to: tr.changes.mapPos(r.to, 1)
59+
});
60+
}
61+
return mapped;
62+
}
63+
return result;
64+
}
65+
});
66+
3467
const MAX_ERRORS = 200;
3568

3669
const MODEDEFS = {
@@ -249,6 +282,8 @@ export class SourceEditor implements ProjectView {
249282

250283
highlightLines.field,
251284

285+
assetRangesField,
286+
252287
createAssetHeaderPlugin((lineNumber: number) => {
253288
window.location.hash = 'asseteditor/' + encodeURIComponent(this.path) + '/' + lineNumber;
254289
}),
@@ -323,7 +358,6 @@ export class SourceEditor implements ProjectView {
323358

324359
replaceTextRange(from: number, to: number, text: string) {
325360
const fromline = this.editor.state.doc.lineAt(from).number;
326-
const toline = this.editor.state.doc.lineAt(to).number;
327361
this.editor.dispatch({
328362
changes: { from, to, insert: text },
329363
annotations: isolateHistory.of("full"),
@@ -334,6 +368,30 @@ export class SourceEditor implements ProjectView {
334368
});
335369
}
336370

371+
setAssetRange(id: string, from: number, to: number) {
372+
this.editor.dispatch({
373+
effects: setAssetRangesEffect.of([{ id, from, to }])
374+
});
375+
}
376+
377+
getAssetText(id: string): string | null {
378+
var range = this.editor.state.field(assetRangesField).get(id);
379+
if (!range) return null;
380+
return this.editor.state.doc.sliceString(range.from, range.to);
381+
}
382+
383+
replaceAssetText(id: string, text: string) {
384+
var range = this.editor.state.field(assetRangesField).get(id);
385+
if (!range) return;
386+
this.replaceTextRange(range.from, range.to, text);
387+
}
388+
389+
clearAssetRanges() {
390+
this.editor.dispatch({
391+
effects: clearAssetRangesEffect.of(undefined)
392+
});
393+
}
394+
337395
insertLinesBefore(text: string) {
338396
const pos = this.editor.state.selection.main.from;
339397
const lineNum = this.editor.state.doc.lineAt(pos).number;

src/ide/windows.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11

22
import $ = require("jquery");
3+
import { getFilenameForPath, getFilenamePrefix } from "../common/util";
4+
import { FileData, WorkerError } from "../common/workertypes";
35
import { CodeProject } from "./project";
4-
import { WorkerError, FileData } from "../common/workertypes";
5-
import { getFilenamePrefix, getFilenameForPath } from "../common/util";
66
import { ProjectView } from "./views/baseviews";
77

88
type WindowCreateFunction = (id: string) => ProjectView;
@@ -176,13 +176,37 @@ export class ProjectWindows {
176176
this.redoStack = [];
177177
}
178178

179-
replaceTextRange(fileid: string, from: number, to: number, text: string) {
179+
setAssetRange(fileid: string, id: string, from: number, to: number) {
180180
var wnd = this.id2window[fileid] || this.create(fileid);
181-
wnd.replaceTextRange(from, to, text);
181+
if (wnd.setAssetRange) {
182+
wnd.setAssetRange(id, from, to);
183+
}
184+
}
185+
186+
getAssetText(fileid: string, id: string): string | null {
187+
var wnd = this.id2window[fileid] || this.create(fileid);
188+
if (wnd.getAssetText) {
189+
return wnd.getAssetText(id);
190+
}
191+
return null;
192+
}
193+
194+
replaceAssetText(fileid: string, id: string, text: string) {
195+
var wnd = this.id2window[fileid] || this.create(fileid);
196+
if (wnd.replaceAssetText) {
197+
wnd.replaceAssetText(id, text);
198+
}
182199
this.undoStack.push({ fileid });
183200
this.redoStack = [];
184201
}
185202

203+
clearAssetRanges(fileid: string) {
204+
var wnd = this.id2window[fileid];
205+
if (wnd && wnd.clearAssetRanges) {
206+
wnd.clearAssetRanges();
207+
}
208+
}
209+
186210
undoStep() {
187211
var entry = this.undoStack.pop();
188212
if (!entry) {

test/cli/testpixelconvert.js

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,41 @@ function dumbEqual(a,b) {
1111
return assert.deepEqual(a,b);
1212
}
1313

14+
// Minimal mock that satisfies TextDataNode's project interface.
15+
function mockProject(text) {
16+
var fileText = text;
17+
var ranges = {};
18+
return {
19+
setAssetRange: function(fileid, id, from, to) { ranges[id] = {from: from, to: to}; },
20+
getAssetText: function(fileid, id) {
21+
var r = ranges[id];
22+
return r ? fileText.substring(r.from, r.to) : null;
23+
},
24+
replaceAssetText: function(fileid, id, newText) {
25+
var r = ranges[id];
26+
if (!r) return;
27+
var delta = newText.length - (r.to - r.from);
28+
fileText = fileText.substring(0, r.from) + newText + fileText.substring(r.to);
29+
var replacedFrom = r.from, replacedTo = r.to;
30+
r.to = r.from + newText.length;
31+
for (var otherId in ranges) {
32+
if (otherId === id) continue;
33+
var other = ranges[otherId];
34+
if (other.from >= replacedTo) other.from += delta;
35+
if (other.to >= replacedTo) other.to += delta;
36+
}
37+
}
38+
};
39+
}
40+
1441
describe('Pixel editor', function() {
1542
it('Should decode', function() {
1643

1744
var fmt = {w:14,h:16,bpp:4,brev:1};
1845
var palfmt = {pal:332,n:16};
1946

2047
var paldatastr = " 0x00, 0x03, 0x19, 0x50, 0x52, 0x07, 0x1f, 0x37, 0xe0, 0xa4, 0xfd, 0xff, 0x38, 0x70, 0x7f, 0x7f, "; // test two entries the same
21-
var node4 = new pixed.TextDataNode(null, null, null, 0, paldatastr.length);
22-
node4.text = paldatastr;
48+
var node4 = new pixed.TextDataNode(mockProject(paldatastr), 'test', 'test', 0, paldatastr.length);
2349
var node5 = new pixed.PaletteFormatToRGB(palfmt);
2450
node4.addRight(node5);
2551
node4.refreshRight();
@@ -51,8 +77,7 @@ describe('Pixel editor', function() {
5177
};
5278

5379
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,";
54-
var node1 = new pixed.TextDataNode(null, null, null, 0, datastr.length);
55-
node1.text = datastr;
80+
var node1 = new pixed.TextDataNode(mockProject(datastr), 'test', 'test', 0, datastr.length);
5681
var node2 = new pixed.Mapper(fmt);
5782
node1.addRight(node2);
5883
var node3 = new pixed.Palettizer(ctx, fmt);

0 commit comments

Comments
 (0)