- Local file support by File System API and file drag-and-drop
- Create New file
- Multi-carets/selections
- Redo/Undo
- Canvas based fast hex/ascii rendering
- Search/Replace (design later)
- The editor supports multiple carets. At first, only a single caret is shown on the offset 0.
- User can move the caret by cursor keys and also, tapping a location by mouse cursor will move the caret at the corresponding offset.
- Tapping a location during pressing
Altkey will spawn a new caret at the corresponding offset. - Moving a caret (from offsetA to offsetB) during pressing
Shiftwill create a selection.- A selection is defined by offsetA and offsetB regardless of the order of offsetA and offsetB; offsetB is the final caret position but offsetB may be smaller than offsetA.
- the actual selected byte range is min(offsetA, offsetB) to max(offsetA, offsetB). the max side is exclusive.
- If there are multiple carets, every caret create its own selection by moving during pressing
Shift.
- The definition of the word, "edit" is defined by replacing existing byte sequence by another byte sequence.
- When user types a character:
- A: if there's a caret with empty selection, the character is inserted at the caret position and the caret offset is incremented.
- B: if there's a caret with some selection bytes, the character is replacing the selected bytes. and the caret offset is moved to the next offset from the inserted character offset.
- In the case A, we can consider it as replacing an empty selection; so the insertion can be always considered to be replacing the selection.
- If the user inserts (i.e. copy-and-paste) longer sentences (byte sequence), the things is same. The byte sequence replaces the existing one.
- The deletion of the character can be also expressed by replacing existing sequence by empty sequence.
- Single
BackspaceorDeleteoperations can be also expressed same way but it does not related to selections but just the caret offsets
OK, so Selection is defined like this;
// No order for offsetA/offsetB.
interface Selection {
offsetA: number;
offsetB: number;
}Edit is a set of replace operations done at once. It is defined like the following interfaces:
interface Edit {
ranges: Range[];
time?: number; // epoch ms when the edit was created (used for merge heuristics)
}
interface Range {
offset: number;
oldData: Uint8Array; // data currently in the buffer
newData: Uint8Array; // data that will replace it
selection?: Selection; // optional Selection associated with the Range
}ranges MUST be sorted by ascending offset and MUST NOT overlap. If multiple carets/selections collide, the caller should merge or drop overlaps before constructing the Edit.
While a selection is defined by two offsets, a range is defined by an offset and paired byte data. The following fragment illustrates the conversion:
const convertSelectionToRange = (
selection: Selection,
replacement: Uint8Array,
data: Uint8Array
): Range => {
const { offsetA, offsetB } = selection;
if (offsetA <= offsetB) {
return { offset: offsetA, oldData: data.slice(offsetA, offsetB), newData: replacement, selection };
} else {
return { offset: offsetB, oldData: data.slice(offsetB, offsetA), newData: replacement, selection };
}
};To convert a selection to a range is easy. But the reverse is impossible; so we have associated the original Selection for reverse conversion cases.
So, every operation on the GUI can be expressed by single Edit. The following fragment illustrates how to apply an Edit to data (applyData function):
interface EditResult {
data: Uint8Array;
selections: Selection[];
}
const newSelection = (selection: Selection, from: number, to: number) => {
if (selection.offsetA <= selection.offsetB) {
return { offsetA: from, offsetB: to };
} else {
return { offsetA: to, offsetB: from };
}
};
const applyEdit = (edit: Edit, data: Uint8Array): EditResult => {
let offset = 0;
let z = 0;
let newData: number[] = [];
let newSelections: Selection[] = [];
for (const r of edit.ranges) {
newData = newData.concat(data.slice(offset, r.offset), Array.from(r.newData));
offset = r.offset + r.oldData.length;
const nextZ = z + r.newData.length - r.oldData.length;
if (r.selection != null) {
newSelections.push(newSelection(r.selection, r.offset + z, offset + nextZ));
}
z = nextZ;
}
newData = newData.concat(data.slice(offset));
return { data: new Uint8Array(newData), selections: newSelections };
};Undo/Redo is managed by the following interface:
interface UndoBuffer {
edits: Edit[];
currentPosition: number;
}Although the explanation order is a little bit strange, we can define redo operation like this:
const redo = (undoBuffer: UndoBuffer, data: Uint8Array): EditResult => {
const edit = undoBuffer.edits[undoBuffer.currentPosition++];
return applyEdit(edit, data);
};The redo operation is really simple. Just do identical process to normal applyEdit except incrementing the undoBuffer's position.
OK, then, how is undo? The code is almost identical to redo except Range.oldData/newData are swapped:
const undo = (undoBuffer: UndoBuffer, data: Uint8Array): EditResult => {
const edit = undoBuffer.edits[--undoBuffer.currentPosition];
let z = 0;
const ranges: Range[] = [];
for (const r of edit.ranges) {
ranges.push({
offset: r.offset + z,
oldData: r.newData,
newData: r.oldData,
selection: r.selection
});
z += (r.newData.length - r.oldData.length);
}
return applyEdit({ ranges }, data);
};When inserting Edit to UndoBuffer,
- Truncate the current
UndoBuffer.editstocurrentPositionlength. - Try to merge the new
Editwith the previousEditif possible:- Only merge if both edits have
timeand the delta between them is within a small window (e.g. 10s). - Only merge if the ranges remain non-overlapping and sorted after concatenation.
- Concatenate the ranges in order:
merged.ranges = prev.ranges.concat(next.ranges), then resort byoffsetif needed. - Do not merge if combining would violate the “sorted and non-overlapping ranges” rule; otherwise the result stops representing a single coherent replace batch.
- Only merge if both edits have