blots-editor 0.0.17 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/asset/language/en.json +3 -1
- package/asset/language/zh.json +3 -1
- package/dist/core/document/history.d.ts +74 -0
- package/dist/core/document/history.js +136 -0
- package/dist/core/document/operations.d.ts +24 -9
- package/dist/core/document/operations.js +29 -1
- package/dist/core/editor-controller.d.ts +16 -4
- package/dist/core/editor-controller.js +30 -24
- package/dist/core/io/session.js +3 -0
- package/dist/core/settings/schema.d.ts +14 -0
- package/dist/core/settings/schema.js +23 -0
- package/dist/core/settings/useSettings.d.ts +4 -0
- package/dist/core/settings/useSettings.js +8 -0
- package/dist/view/page/editor.js +8 -0
- package/dist/view/page/settings.js +55 -1
- package/dist/view/utils/sensitivity-bar.d.ts +42 -8
- package/dist/view/utils/sensitivity-bar.js +54 -18
- package/package.json +1 -1
package/asset/language/en.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"settings.language.zh": "中文",
|
|
10
10
|
"settings.sensitivity.cursor": "Cursor Wheel Sensitivity",
|
|
11
11
|
"settings.sensitivity.view": "View Scroll Sensitivity",
|
|
12
|
-
"settings.
|
|
12
|
+
"settings.slider.hint": "Click or drag to adjust",
|
|
13
13
|
"settings.back": "Back",
|
|
14
14
|
"editor.mode.insert": "INSERT",
|
|
15
15
|
"editor.mode.normal": "NORMAL",
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"settings.fileTreeRoot": "FileTree Root",
|
|
32
32
|
"settings.fileTreeRoot.startup": "Startup directory",
|
|
33
33
|
"settings.fileTreeRoot.custom": "Custom path",
|
|
34
|
+
"settings.mergeWindow": "Undo Merge Window",
|
|
35
|
+
"settings.mergeWindow.off": "Off",
|
|
34
36
|
"settings.ok": "OK",
|
|
35
37
|
"settings.cancel": "Cancel"
|
|
36
38
|
}
|
package/asset/language/zh.json
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"settings.language.zh": "中文",
|
|
10
10
|
"settings.sensitivity.cursor": "光标滚动灵敏度",
|
|
11
11
|
"settings.sensitivity.view": "视图滚动灵敏度",
|
|
12
|
-
"settings.
|
|
12
|
+
"settings.slider.hint": "点击或拖动调整",
|
|
13
13
|
"settings.back": "返回",
|
|
14
14
|
"editor.mode.insert": "插入",
|
|
15
15
|
"editor.mode.normal": "普通",
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
"settings.fileTreeRoot": "文件树根目录",
|
|
32
32
|
"settings.fileTreeRoot.startup": "启动目录",
|
|
33
33
|
"settings.fileTreeRoot.custom": "自定义路径",
|
|
34
|
+
"settings.mergeWindow": "撤销合并时间",
|
|
35
|
+
"settings.mergeWindow.off": "关闭",
|
|
34
36
|
"settings.ok": "确定",
|
|
35
37
|
"settings.cancel": "取消"
|
|
36
38
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Document } from "./document.js";
|
|
2
|
+
import type { EditOperation } from "./operations.js";
|
|
3
|
+
import type { Position } from "./position.js";
|
|
4
|
+
/** One undoable edit: the operation plus the cursor before and after it ran. */
|
|
5
|
+
export type HistoryEntry = {
|
|
6
|
+
op: EditOperation;
|
|
7
|
+
/** Cursor before `apply`; undo leaves the cursor here. */
|
|
8
|
+
cursorBefore: Position;
|
|
9
|
+
/** Cursor after `apply`; redo leaves the cursor here, and undo starts here. */
|
|
10
|
+
cursorAfter: Position;
|
|
11
|
+
/** When the edit ran, used to group consecutive edits into one undo step. */
|
|
12
|
+
time: number;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Linear undo/redo stack over {@link EditOperation}s.
|
|
16
|
+
*
|
|
17
|
+
* Stores each operation together with the cursor positions around it. The
|
|
18
|
+
* cursor is part of the record — not reconstructed from the operation —
|
|
19
|
+
* because editing and cursor movement are independent: the user may move the
|
|
20
|
+
* cursor (an action that is not itself undoable) before pressing undo, so the
|
|
21
|
+
* position `invert` expects cannot be assumed to still hold.
|
|
22
|
+
*
|
|
23
|
+
* A burst of mergeable edits (see {@link EditOperation.merge}) that land next
|
|
24
|
+
* to each other within the merge window collapses into one entry, so typing a
|
|
25
|
+
* word undoes as a word rather than a character. Any other edit, a cursor
|
|
26
|
+
* move, or a pause longer than the window starts a new entry. The window is a
|
|
27
|
+
* user setting (see `setMergeWindow`); `0` disables merging.
|
|
28
|
+
*/
|
|
29
|
+
export declare class History {
|
|
30
|
+
private readonly _undo;
|
|
31
|
+
private readonly _redo;
|
|
32
|
+
private readonly _limit;
|
|
33
|
+
private readonly _now;
|
|
34
|
+
private _mergeWindow;
|
|
35
|
+
constructor(limit?: number, now?: () => number);
|
|
36
|
+
/**
|
|
37
|
+
* Set the coalescing window in ms. Edits closer together than this share an
|
|
38
|
+
* undo step; `0` disables merging, so every edit stands alone.
|
|
39
|
+
*/
|
|
40
|
+
setMergeWindow(ms: number): void;
|
|
41
|
+
canUndo(): boolean;
|
|
42
|
+
canRedo(): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Apply `op` and record it. Capturing the cursor here (rather than in the
|
|
45
|
+
* caller) keeps the entry and the operation's effects in sync by
|
|
46
|
+
* construction. A new edit discards the redo branch, since the document
|
|
47
|
+
* has diverged from the state those entries would replay onto.
|
|
48
|
+
*
|
|
49
|
+
* A no-op edit (e.g. backspace at the document start) is not recorded: its
|
|
50
|
+
* inverse would do nothing, so an entry for it would only make the user
|
|
51
|
+
* press undo twice to skip past it and would evict real history at the
|
|
52
|
+
* limit. Leaving the redo branch intact is correct too — the document did
|
|
53
|
+
* not diverge from the state those entries replay onto.
|
|
54
|
+
*/
|
|
55
|
+
run(op: EditOperation, doc: Document): void;
|
|
56
|
+
/**
|
|
57
|
+
* Fold `entry` into `top` when it continues the same edit: the newer
|
|
58
|
+
* cursor picks up where the older left off, the operation agrees to merge,
|
|
59
|
+
* and the gap is within the time window. Returns false when `entry` must
|
|
60
|
+
* start its own step. Mutating `top.op` only happens on success, since
|
|
61
|
+
* {@link EditOperation.merge} is a no-op when it returns false.
|
|
62
|
+
*/
|
|
63
|
+
private _merge;
|
|
64
|
+
/** Undo the last edit; returns false when the undo stack is empty. */
|
|
65
|
+
undo(doc: Document): boolean;
|
|
66
|
+
/** Redo the last undone edit; returns false when the redo stack is empty. */
|
|
67
|
+
redo(doc: Document): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Drop all history. Required after replacing the document wholesale
|
|
70
|
+
* (`Document.setText`), since the recorded positions then refer to a
|
|
71
|
+
* different text.
|
|
72
|
+
*/
|
|
73
|
+
clear(): void;
|
|
74
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { MERGE_WINDOW_DEFAULT } from "../settings/schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Linear undo/redo stack over {@link EditOperation}s.
|
|
4
|
+
*
|
|
5
|
+
* Stores each operation together with the cursor positions around it. The
|
|
6
|
+
* cursor is part of the record — not reconstructed from the operation —
|
|
7
|
+
* because editing and cursor movement are independent: the user may move the
|
|
8
|
+
* cursor (an action that is not itself undoable) before pressing undo, so the
|
|
9
|
+
* position `invert` expects cannot be assumed to still hold.
|
|
10
|
+
*
|
|
11
|
+
* A burst of mergeable edits (see {@link EditOperation.merge}) that land next
|
|
12
|
+
* to each other within the merge window collapses into one entry, so typing a
|
|
13
|
+
* word undoes as a word rather than a character. Any other edit, a cursor
|
|
14
|
+
* move, or a pause longer than the window starts a new entry. The window is a
|
|
15
|
+
* user setting (see `setMergeWindow`); `0` disables merging.
|
|
16
|
+
*/
|
|
17
|
+
export class History {
|
|
18
|
+
_undo = [];
|
|
19
|
+
_redo = [];
|
|
20
|
+
_limit;
|
|
21
|
+
_now;
|
|
22
|
+
_mergeWindow = MERGE_WINDOW_DEFAULT;
|
|
23
|
+
constructor(limit = 200, now = Date.now) {
|
|
24
|
+
this._limit = limit;
|
|
25
|
+
this._now = now;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Set the coalescing window in ms. Edits closer together than this share an
|
|
29
|
+
* undo step; `0` disables merging, so every edit stands alone.
|
|
30
|
+
*/
|
|
31
|
+
setMergeWindow(ms) {
|
|
32
|
+
this._mergeWindow = ms;
|
|
33
|
+
}
|
|
34
|
+
canUndo() {
|
|
35
|
+
return this._undo.length > 0;
|
|
36
|
+
}
|
|
37
|
+
canRedo() {
|
|
38
|
+
return this._redo.length > 0;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Apply `op` and record it. Capturing the cursor here (rather than in the
|
|
42
|
+
* caller) keeps the entry and the operation's effects in sync by
|
|
43
|
+
* construction. A new edit discards the redo branch, since the document
|
|
44
|
+
* has diverged from the state those entries would replay onto.
|
|
45
|
+
*
|
|
46
|
+
* A no-op edit (e.g. backspace at the document start) is not recorded: its
|
|
47
|
+
* inverse would do nothing, so an entry for it would only make the user
|
|
48
|
+
* press undo twice to skip past it and would evict real history at the
|
|
49
|
+
* limit. Leaving the redo branch intact is correct too — the document did
|
|
50
|
+
* not diverge from the state those entries replay onto.
|
|
51
|
+
*/
|
|
52
|
+
run(op, doc) {
|
|
53
|
+
const before = doc.cursor;
|
|
54
|
+
if (!op.apply(doc)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const after = doc.cursor;
|
|
58
|
+
const entry = {
|
|
59
|
+
op,
|
|
60
|
+
cursorBefore: { line: before.line, logical: before.logical },
|
|
61
|
+
cursorAfter: { line: after.line, logical: after.logical },
|
|
62
|
+
time: this._now(),
|
|
63
|
+
};
|
|
64
|
+
const top = this._undo[this._undo.length - 1];
|
|
65
|
+
if (!(top && this._merge(top, entry))) {
|
|
66
|
+
this._undo.push(entry);
|
|
67
|
+
if (this._undo.length > this._limit) {
|
|
68
|
+
this._undo.shift();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
this._redo.length = 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Fold `entry` into `top` when it continues the same edit: the newer
|
|
75
|
+
* cursor picks up where the older left off, the operation agrees to merge,
|
|
76
|
+
* and the gap is within the time window. Returns false when `entry` must
|
|
77
|
+
* start its own step. Mutating `top.op` only happens on success, since
|
|
78
|
+
* {@link EditOperation.merge} is a no-op when it returns false.
|
|
79
|
+
*/
|
|
80
|
+
_merge(top, entry) {
|
|
81
|
+
if (entry.time - top.time >= this._mergeWindow) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (entry.cursorBefore.line !== top.cursorAfter.line ||
|
|
85
|
+
entry.cursorBefore.logical !== top.cursorAfter.logical) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (!top.op.merge?.(entry.op)) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
// The merged operation now spans both edits: its end cursor moves to
|
|
92
|
+
// the newer entry's, and the timestamp advances so a pause after this
|
|
93
|
+
// edit still ends the group.
|
|
94
|
+
top.cursorAfter = entry.cursorAfter;
|
|
95
|
+
top.time = entry.time;
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
/** Undo the last edit; returns false when the undo stack is empty. */
|
|
99
|
+
undo(doc) {
|
|
100
|
+
const entry = this._undo.pop();
|
|
101
|
+
if (!entry) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
// invert() assumes the cursor sits where apply() left it — restore
|
|
105
|
+
// cursorAfter first, or a since-moved cursor makes it delete the wrong
|
|
106
|
+
// span. cursorBefore after the invert is the position the user expects.
|
|
107
|
+
doc.setCursor(entry.cursorAfter.line, entry.cursorAfter.logical);
|
|
108
|
+
entry.op.invert(doc);
|
|
109
|
+
doc.setCursor(entry.cursorBefore.line, entry.cursorBefore.logical);
|
|
110
|
+
this._redo.push(entry);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/** Redo the last undone edit; returns false when the redo stack is empty. */
|
|
114
|
+
redo(doc) {
|
|
115
|
+
const entry = this._redo.pop();
|
|
116
|
+
if (!entry) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
// apply() runs against the pre-edit document, so restore cursorBefore
|
|
120
|
+
// first; apply() then reproduces cursorAfter on its own.
|
|
121
|
+
doc.setCursor(entry.cursorBefore.line, entry.cursorBefore.logical);
|
|
122
|
+
entry.op.apply(doc);
|
|
123
|
+
doc.setCursor(entry.cursorAfter.line, entry.cursorAfter.logical);
|
|
124
|
+
this._undo.push(entry);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Drop all history. Required after replacing the document wholesale
|
|
129
|
+
* (`Document.setText`), since the recorded positions then refer to a
|
|
130
|
+
* different text.
|
|
131
|
+
*/
|
|
132
|
+
clear() {
|
|
133
|
+
this._undo.length = 0;
|
|
134
|
+
this._redo.length = 0;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -9,33 +9,47 @@ import type { Document } from "./document.js";
|
|
|
9
9
|
* cursor before inverting.
|
|
10
10
|
*/
|
|
11
11
|
export type EditOperation = {
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Apply the edit in place. Returns false when the edit was a no-op (the
|
|
14
|
+
* document is unchanged), so history can skip recording a dead step.
|
|
15
|
+
*/
|
|
16
|
+
apply(doc: Document): boolean;
|
|
13
17
|
invert(doc: Document): void;
|
|
18
|
+
/**
|
|
19
|
+
* Absorb a following edit into this one so the two share a single undo
|
|
20
|
+
* step (e.g. two adjacent insertions form one typed run). Returns true
|
|
21
|
+
* after folding `next` in, or false when the edit kind differs and they
|
|
22
|
+
* must stay separate. History calls this only for edits whose cursors are
|
|
23
|
+
* adjacent and within its merge window, so contiguity can be assumed.
|
|
24
|
+
*/
|
|
25
|
+
merge?(next: EditOperation): boolean;
|
|
14
26
|
};
|
|
15
27
|
/** Insert a string at the cursor; invert deletes exactly the inserted text. */
|
|
16
28
|
export declare class InsertTextOp implements EditOperation {
|
|
17
|
-
private
|
|
29
|
+
private text;
|
|
18
30
|
constructor(text: string);
|
|
19
|
-
apply(doc: Document):
|
|
31
|
+
apply(doc: Document): boolean;
|
|
20
32
|
invert(doc: Document): void;
|
|
33
|
+
/** Merge only with another insertion; anything else ends the typed run. */
|
|
34
|
+
merge(next: EditOperation): boolean;
|
|
21
35
|
}
|
|
22
36
|
/** Backspace: delete the char before the cursor, or join with the previous line at column 0. */
|
|
23
37
|
export declare class DeleteBeforeOp implements EditOperation {
|
|
24
38
|
private _deletedChar;
|
|
25
39
|
private _joined;
|
|
26
|
-
apply(doc: Document):
|
|
40
|
+
apply(doc: Document): boolean;
|
|
27
41
|
invert(doc: Document): void;
|
|
28
42
|
}
|
|
29
43
|
/** Delete key: remove the char after the cursor, or join with the next line at end of line. */
|
|
30
44
|
export declare class DeleteAfterOp implements EditOperation {
|
|
31
45
|
private _deletedChar;
|
|
32
46
|
private _joined;
|
|
33
|
-
apply(doc: Document):
|
|
47
|
+
apply(doc: Document): boolean;
|
|
34
48
|
invert(doc: Document): void;
|
|
35
49
|
}
|
|
36
50
|
/** Enter: split the current line at the cursor; invert rejoins the two lines. */
|
|
37
51
|
export declare class SplitLineOp implements EditOperation {
|
|
38
|
-
apply(doc: Document):
|
|
52
|
+
apply(doc: Document): boolean;
|
|
39
53
|
invert(doc: Document): void;
|
|
40
54
|
}
|
|
41
55
|
/**
|
|
@@ -45,17 +59,18 @@ export declare class SplitLineOp implements EditOperation {
|
|
|
45
59
|
*/
|
|
46
60
|
export declare class JoinLineOp implements EditOperation {
|
|
47
61
|
private _joinAt;
|
|
48
|
-
|
|
62
|
+
private _joined;
|
|
63
|
+
apply(doc: Document): boolean;
|
|
49
64
|
invert(doc: Document): void;
|
|
50
65
|
}
|
|
51
66
|
/** Indent the current line by `indentWidth` spaces, moving the cursor along. */
|
|
52
67
|
export declare class IndentOp implements EditOperation {
|
|
53
|
-
apply(doc: Document):
|
|
68
|
+
apply(doc: Document): boolean;
|
|
54
69
|
invert(doc: Document): void;
|
|
55
70
|
}
|
|
56
71
|
/** Outdent up to `indentWidth` leading spaces; invert re-adds exactly what was removed. */
|
|
57
72
|
export declare class OutdentOp implements EditOperation {
|
|
58
73
|
private _removed;
|
|
59
|
-
apply(doc: Document):
|
|
74
|
+
apply(doc: Document): boolean;
|
|
60
75
|
invert(doc: Document): void;
|
|
61
76
|
}
|
|
@@ -9,6 +9,7 @@ export class InsertTextOp {
|
|
|
9
9
|
const cur = doc.getLine(line);
|
|
10
10
|
doc.setLine(line, cur.slice(0, logical) + this.text + cur.slice(logical));
|
|
11
11
|
doc.setCursor(line, logical + this.text.length);
|
|
12
|
+
return true;
|
|
12
13
|
}
|
|
13
14
|
invert(doc) {
|
|
14
15
|
const { line, logical } = doc.cursor;
|
|
@@ -17,6 +18,14 @@ export class InsertTextOp {
|
|
|
17
18
|
doc.setLine(line, cur.slice(0, start) + cur.slice(logical));
|
|
18
19
|
doc.setCursor(line, start);
|
|
19
20
|
}
|
|
21
|
+
/** Merge only with another insertion; anything else ends the typed run. */
|
|
22
|
+
merge(next) {
|
|
23
|
+
if (!(next instanceof InsertTextOp)) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
this.text += next.text;
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
20
29
|
}
|
|
21
30
|
/** Backspace: delete the char before the cursor, or join with the previous line at column 0. */
|
|
22
31
|
export class DeleteBeforeOp {
|
|
@@ -35,6 +44,7 @@ export class DeleteBeforeOp {
|
|
|
35
44
|
this._joined = false;
|
|
36
45
|
doc.setLine(line, cur.slice(0, logical - units) + cur.slice(logical));
|
|
37
46
|
doc.setCursor(line, logical - units);
|
|
47
|
+
return true;
|
|
38
48
|
}
|
|
39
49
|
else if (line > 0) {
|
|
40
50
|
const prev = doc.getLine(line - 1);
|
|
@@ -43,7 +53,9 @@ export class DeleteBeforeOp {
|
|
|
43
53
|
doc.removeLineAt(line);
|
|
44
54
|
doc.setCursor(line - 1, prev.length);
|
|
45
55
|
this._joined = true;
|
|
56
|
+
return true;
|
|
46
57
|
}
|
|
58
|
+
return false;
|
|
47
59
|
}
|
|
48
60
|
invert(doc) {
|
|
49
61
|
if (!this._joined) {
|
|
@@ -78,13 +90,16 @@ export class DeleteAfterOp {
|
|
|
78
90
|
this._deletedChar = cur.slice(logical, logical + units);
|
|
79
91
|
this._joined = false;
|
|
80
92
|
doc.setLine(line, cur.slice(0, logical) + cur.slice(logical + units));
|
|
93
|
+
return true;
|
|
81
94
|
}
|
|
82
95
|
else if (line < doc.lineCount - 1) {
|
|
83
96
|
const next = doc.getLine(line + 1);
|
|
84
97
|
doc.setLine(line, cur + next);
|
|
85
98
|
doc.removeLineAt(line + 1);
|
|
86
99
|
this._joined = true;
|
|
100
|
+
return true;
|
|
87
101
|
}
|
|
102
|
+
return false;
|
|
88
103
|
}
|
|
89
104
|
invert(doc) {
|
|
90
105
|
if (!this._joined) {
|
|
@@ -108,6 +123,7 @@ export class SplitLineOp {
|
|
|
108
123
|
doc.setLine(line, cur.slice(0, logical));
|
|
109
124
|
doc.insertLineAt(line + 1, cur.slice(logical));
|
|
110
125
|
doc.setCursor(line + 1, 0);
|
|
126
|
+
return true;
|
|
111
127
|
}
|
|
112
128
|
invert(doc) {
|
|
113
129
|
// Cursor is on the newly created line; the split point is its own start.
|
|
@@ -126,18 +142,27 @@ export class SplitLineOp {
|
|
|
126
142
|
*/
|
|
127
143
|
export class JoinLineOp {
|
|
128
144
|
_joinAt = 0;
|
|
145
|
+
_joined = false;
|
|
129
146
|
apply(doc) {
|
|
147
|
+
this._joined = false;
|
|
130
148
|
const { line } = doc.cursor;
|
|
131
149
|
if (line >= doc.lineCount - 1) {
|
|
132
|
-
return;
|
|
150
|
+
return false;
|
|
133
151
|
}
|
|
134
152
|
const cur = doc.getLine(line);
|
|
135
153
|
const next = doc.getLine(line + 1);
|
|
136
154
|
this._joinAt = cur.length;
|
|
155
|
+
this._joined = true;
|
|
137
156
|
doc.setLine(line, cur + next);
|
|
138
157
|
doc.removeLineAt(line + 1);
|
|
158
|
+
return true;
|
|
139
159
|
}
|
|
140
160
|
invert(doc) {
|
|
161
|
+
// apply() no-ops on the last line; inverting that split would corrupt
|
|
162
|
+
// the document, so mirror the no-op.
|
|
163
|
+
if (!this._joined) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
141
166
|
const { line } = doc.cursor;
|
|
142
167
|
const cur = doc.getLine(line);
|
|
143
168
|
doc.setLine(line, cur.slice(0, this._joinAt));
|
|
@@ -152,6 +177,7 @@ export class IndentOp {
|
|
|
152
177
|
const spaces = " ".repeat(doc.indentWidth);
|
|
153
178
|
doc.setLine(line, spaces + cur);
|
|
154
179
|
doc.setCursor(line, logical + doc.indentWidth);
|
|
180
|
+
return true;
|
|
155
181
|
}
|
|
156
182
|
invert(doc) {
|
|
157
183
|
const { line, logical } = doc.cursor;
|
|
@@ -172,7 +198,9 @@ export class OutdentOp {
|
|
|
172
198
|
if (remove > 0) {
|
|
173
199
|
doc.setLine(line, cur.slice(remove));
|
|
174
200
|
doc.setCursor(line, Math.max(0, logical - remove));
|
|
201
|
+
return true;
|
|
175
202
|
}
|
|
203
|
+
return false;
|
|
176
204
|
}
|
|
177
205
|
invert(doc) {
|
|
178
206
|
if (this._removed === 0) {
|
|
@@ -1,24 +1,36 @@
|
|
|
1
1
|
import { Document } from "./document/document.js";
|
|
2
|
+
import { History } from "./document/history.js";
|
|
3
|
+
import { type EditOperation } from "./document/operations.js";
|
|
2
4
|
export type EditorOptions = {
|
|
3
5
|
indentWidth?: number;
|
|
4
6
|
};
|
|
5
7
|
export type EditorCommandArgs = Record<string, unknown> | undefined;
|
|
6
|
-
|
|
8
|
+
/**
|
|
9
|
+
* A command handler. Returning an {@link EditOperation} makes the command
|
|
10
|
+
* undoable — `execute` routes the operation through the {@link History},
|
|
11
|
+
* which applies it and records the cursor positions around it. Returning
|
|
12
|
+
* nothing marks a command that mutates the document directly or only moves
|
|
13
|
+
* the cursor; neither is recorded.
|
|
14
|
+
*/
|
|
15
|
+
export type EditorCommandHandler = (doc: Document, args: EditorCommandArgs) => EditOperation | void;
|
|
7
16
|
type ChangeListener = () => void;
|
|
8
17
|
/**
|
|
9
18
|
* Coordinator between the pure core and the keymap/render layers.
|
|
10
19
|
*
|
|
11
20
|
* Owns the document, a command registry (so P2 vim mode only swaps key
|
|
12
|
-
* bindings, never touching the core),
|
|
13
|
-
*
|
|
14
|
-
*
|
|
21
|
+
* bindings, never touching the core), the change-notification channel the
|
|
22
|
+
* view subscribes to, and the undo/redo {@link History}. Editing commands
|
|
23
|
+
* return an atomic operation carrying its own `invert`, which `execute`
|
|
24
|
+
* routes through the history; movement commands return nothing.
|
|
15
25
|
*/
|
|
16
26
|
export declare class EditorController {
|
|
17
27
|
private readonly _document;
|
|
18
28
|
private readonly _commands;
|
|
19
29
|
private readonly _listeners;
|
|
30
|
+
private readonly _history;
|
|
20
31
|
constructor(text: string, options?: EditorOptions);
|
|
21
32
|
get document(): Document;
|
|
33
|
+
get history(): History;
|
|
22
34
|
onChange(listener: ChangeListener): () => void;
|
|
23
35
|
defineCommand(id: string, handler: EditorCommandHandler): this;
|
|
24
36
|
execute(id: string, args?: EditorCommandArgs): void;
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { Document } from "./document/document.js";
|
|
2
|
+
import { History } from "./document/history.js";
|
|
2
3
|
import { DeleteAfterOp, DeleteBeforeOp, IndentOp, InsertTextOp, JoinLineOp, OutdentOp, SplitLineOp, } from "./document/operations.js";
|
|
3
4
|
/**
|
|
4
5
|
* Coordinator between the pure core and the keymap/render layers.
|
|
5
6
|
*
|
|
6
7
|
* Owns the document, a command registry (so P2 vim mode only swaps key
|
|
7
|
-
* bindings, never touching the core),
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* bindings, never touching the core), the change-notification channel the
|
|
9
|
+
* view subscribes to, and the undo/redo {@link History}. Editing commands
|
|
10
|
+
* return an atomic operation carrying its own `invert`, which `execute`
|
|
11
|
+
* routes through the history; movement commands return nothing.
|
|
10
12
|
*/
|
|
11
13
|
export class EditorController {
|
|
12
14
|
_document;
|
|
13
15
|
_commands = new Map();
|
|
14
16
|
_listeners = new Set();
|
|
17
|
+
_history = new History();
|
|
15
18
|
constructor(text, options = {}) {
|
|
16
19
|
this._document = new Document(text, { indentWidth: options.indentWidth });
|
|
17
20
|
this._registerBuiltins();
|
|
@@ -19,6 +22,9 @@ export class EditorController {
|
|
|
19
22
|
get document() {
|
|
20
23
|
return this._document;
|
|
21
24
|
}
|
|
25
|
+
get history() {
|
|
26
|
+
return this._history;
|
|
27
|
+
}
|
|
22
28
|
onChange(listener) {
|
|
23
29
|
this._listeners.add(listener);
|
|
24
30
|
return () => {
|
|
@@ -34,33 +40,33 @@ export class EditorController {
|
|
|
34
40
|
if (!handler) {
|
|
35
41
|
throw new Error(`[ink-cartridge] Unknown editor command: ${id}`);
|
|
36
42
|
}
|
|
37
|
-
handler(this._document, args);
|
|
43
|
+
const op = handler(this._document, args);
|
|
44
|
+
if (op) {
|
|
45
|
+
this._history.run(op, this._document);
|
|
46
|
+
}
|
|
38
47
|
this._listeners.forEach((fn) => fn());
|
|
39
48
|
}
|
|
40
49
|
_registerBuiltins() {
|
|
41
|
-
|
|
50
|
+
// Editing commands return their operation instead of applying it:
|
|
51
|
+
// `execute` hands it to the history, which applies it and records the
|
|
52
|
+
// cursor positions around it (see History.run).
|
|
53
|
+
this.defineCommand("editor.insertText", (_doc, args) => {
|
|
42
54
|
const text = typeof args?.text === "string" ? args.text : "";
|
|
43
|
-
|
|
44
|
-
new InsertTextOp(text).apply(doc);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
this.defineCommand("editor.deleteBefore", (doc) => {
|
|
48
|
-
new DeleteBeforeOp().apply(doc);
|
|
49
|
-
});
|
|
50
|
-
this.defineCommand("editor.deleteAfter", (doc) => {
|
|
51
|
-
new DeleteAfterOp().apply(doc);
|
|
52
|
-
});
|
|
53
|
-
this.defineCommand("editor.splitLine", (doc) => {
|
|
54
|
-
new SplitLineOp().apply(doc);
|
|
55
|
-
});
|
|
56
|
-
this.defineCommand("editor.joinLine", (doc) => {
|
|
57
|
-
new JoinLineOp().apply(doc);
|
|
55
|
+
return text ? new InsertTextOp(text) : undefined;
|
|
58
56
|
});
|
|
59
|
-
this.defineCommand("editor.
|
|
60
|
-
|
|
57
|
+
this.defineCommand("editor.deleteBefore", () => new DeleteBeforeOp());
|
|
58
|
+
this.defineCommand("editor.deleteAfter", () => new DeleteAfterOp());
|
|
59
|
+
this.defineCommand("editor.splitLine", () => new SplitLineOp());
|
|
60
|
+
this.defineCommand("editor.joinLine", () => new JoinLineOp());
|
|
61
|
+
this.defineCommand("editor.indent", () => new IndentOp());
|
|
62
|
+
this.defineCommand("editor.outdent", () => new OutdentOp());
|
|
63
|
+
// Undo/redo drive the history directly; they are not edits themselves,
|
|
64
|
+
// so they return nothing and are never recorded.
|
|
65
|
+
this.defineCommand("history.undo", () => {
|
|
66
|
+
this._history.undo(this._document);
|
|
61
67
|
});
|
|
62
|
-
this.defineCommand("
|
|
63
|
-
|
|
68
|
+
this.defineCommand("history.redo", () => {
|
|
69
|
+
this._history.redo(this._document);
|
|
64
70
|
});
|
|
65
71
|
this.defineCommand("cursor.moveLeft", (doc) => doc.moveLeft());
|
|
66
72
|
this.defineCommand("cursor.moveRight", (doc) => doc.moveRight());
|
package/dist/core/io/session.js
CHANGED
|
@@ -73,6 +73,9 @@ export class EditorSession {
|
|
|
73
73
|
return { ok: false, error: "Binary file" };
|
|
74
74
|
}
|
|
75
75
|
this._controller.document.setText(text);
|
|
76
|
+
// Recorded positions refer to the previous text, so the history is
|
|
77
|
+
// meaningless once the document is replaced.
|
|
78
|
+
this._controller.history.clear();
|
|
76
79
|
this._filePath = filePath;
|
|
77
80
|
// Hash the NORMALIZED document text, not the raw disk bytes: the
|
|
78
81
|
// document normalizes CRLF to LF, so hashing the raw text would
|
|
@@ -9,6 +9,16 @@ import { z } from "zod";
|
|
|
9
9
|
*/
|
|
10
10
|
/** Wheel sensitivity in visual lines per notch, 1.0–10.0, 0.5 steps. */
|
|
11
11
|
export declare const sensitivitySchema: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
/** Undo coalescing range in ms; `0` disables merging. */
|
|
13
|
+
export declare const MERGE_WINDOW_MIN = 0;
|
|
14
|
+
export declare const MERGE_WINDOW_MAX = 2000;
|
|
15
|
+
export declare const MERGE_WINDOW_STEP = 100;
|
|
16
|
+
export declare const MERGE_WINDOW_DEFAULT = 500;
|
|
17
|
+
/**
|
|
18
|
+
* Consecutive mergeable edits closer than this (ms) collapse into one undo
|
|
19
|
+
* step. `0` turns merging off, so every edit is its own step.
|
|
20
|
+
*/
|
|
21
|
+
export declare const mergeWindowSchema: z.ZodDefault<z.ZodNumber>;
|
|
12
22
|
/** File-tree root source: the process startup directory or a custom path. */
|
|
13
23
|
export declare const fileTreeRootSchema: z.ZodDefault<z.ZodEnum<{
|
|
14
24
|
startup: "startup";
|
|
@@ -27,10 +37,14 @@ export declare const settingsSchema: z.ZodObject<{
|
|
|
27
37
|
}>>;
|
|
28
38
|
customPath: z.ZodDefault<z.ZodString>;
|
|
29
39
|
}, z.core.$strip>>;
|
|
40
|
+
history: z.ZodDefault<z.ZodObject<{
|
|
41
|
+
mergeWindow: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
}, z.core.$strip>>;
|
|
30
43
|
}, z.core.$strip>;
|
|
31
44
|
export type EditorSettings = z.infer<typeof settingsSchema>;
|
|
32
45
|
export type WheelSensitivity = EditorSettings["wheel"];
|
|
33
46
|
export type FileTreeSettings = EditorSettings["fileTree"];
|
|
47
|
+
export type HistorySettings = EditorSettings["history"];
|
|
34
48
|
export declare const DEFAULT_SETTINGS: EditorSettings;
|
|
35
49
|
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
36
50
|
export declare function parseSettings(raw: unknown): EditorSettings;
|
|
@@ -14,6 +14,22 @@ export const sensitivitySchema = z
|
|
|
14
14
|
.max(10)
|
|
15
15
|
.step(0.5)
|
|
16
16
|
.default(1);
|
|
17
|
+
/** Undo coalescing range in ms; `0` disables merging. */
|
|
18
|
+
export const MERGE_WINDOW_MIN = 0;
|
|
19
|
+
export const MERGE_WINDOW_MAX = 2000;
|
|
20
|
+
export const MERGE_WINDOW_STEP = 100;
|
|
21
|
+
export const MERGE_WINDOW_DEFAULT = 500;
|
|
22
|
+
/**
|
|
23
|
+
* Consecutive mergeable edits closer than this (ms) collapse into one undo
|
|
24
|
+
* step. `0` turns merging off, so every edit is its own step.
|
|
25
|
+
*/
|
|
26
|
+
export const mergeWindowSchema = z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(MERGE_WINDOW_MIN)
|
|
30
|
+
.max(MERGE_WINDOW_MAX)
|
|
31
|
+
.multipleOf(MERGE_WINDOW_STEP)
|
|
32
|
+
.default(MERGE_WINDOW_DEFAULT);
|
|
17
33
|
const DEFAULT_WHEEL = { cursor: 1, view: 3 };
|
|
18
34
|
/** File-tree root source: the process startup directory or a custom path. */
|
|
19
35
|
export const fileTreeRootSchema = z.enum(["startup", "custom"]).default("startup");
|
|
@@ -36,11 +52,18 @@ export const settingsSchema = z.object({
|
|
|
36
52
|
customPath: z.string().default(""),
|
|
37
53
|
})
|
|
38
54
|
.default({ root: "startup", customPath: "" }),
|
|
55
|
+
history: z
|
|
56
|
+
.object({
|
|
57
|
+
/** Undo coalescing window in ms; see {@link mergeWindowSchema}. */
|
|
58
|
+
mergeWindow: mergeWindowSchema,
|
|
59
|
+
})
|
|
60
|
+
.default({ mergeWindow: MERGE_WINDOW_DEFAULT }),
|
|
39
61
|
});
|
|
40
62
|
export const DEFAULT_SETTINGS = {
|
|
41
63
|
language: "en",
|
|
42
64
|
wheel: DEFAULT_WHEEL,
|
|
43
65
|
fileTree: { root: "startup", customPath: "" },
|
|
66
|
+
history: { mergeWindow: MERGE_WINDOW_DEFAULT },
|
|
44
67
|
};
|
|
45
68
|
/** Parse an unknown persisted value, falling back to defaults on any failure. */
|
|
46
69
|
export function parseSettings(raw) {
|
|
@@ -18,6 +18,10 @@ export type SettingsApi = {
|
|
|
18
18
|
setLanguage: (code: string) => void;
|
|
19
19
|
/** Persist the file-tree root settings. */
|
|
20
20
|
setFileTree: (fileTree: EditorSettings["fileTree"]) => void;
|
|
21
|
+
/** Set the undo merge window (ms) and persist it immediately. */
|
|
22
|
+
setMergeWindow: (ms: number) => void;
|
|
23
|
+
/** Update the merge window in memory only (while dragging); commit on release. */
|
|
24
|
+
setMergeWindowDraft: (ms: number) => void;
|
|
21
25
|
};
|
|
22
26
|
/**
|
|
23
27
|
* React binding for the shared {@link settingsStore}. Re-renders subscribers
|
|
@@ -27,6 +27,14 @@ export function useSettings() {
|
|
|
27
27
|
commit: () => settingsStore.commit(),
|
|
28
28
|
setLanguage: (code) => settingsStore.persist({ ...settings, language: code }),
|
|
29
29
|
setFileTree: (fileTree) => settingsStore.persist({ ...settings, fileTree }),
|
|
30
|
+
setMergeWindow: (ms) => settingsStore.persist({
|
|
31
|
+
...settings,
|
|
32
|
+
history: { ...settings.history, mergeWindow: ms },
|
|
33
|
+
}),
|
|
34
|
+
setMergeWindowDraft: (ms) => settingsStore.update({
|
|
35
|
+
...settings,
|
|
36
|
+
history: { ...settings.history, mergeWindow: ms },
|
|
37
|
+
}),
|
|
30
38
|
};
|
|
31
39
|
}, [settings]);
|
|
32
40
|
}
|
package/dist/view/page/editor.js
CHANGED
|
@@ -152,6 +152,11 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
152
152
|
useEffect(() => {
|
|
153
153
|
return boundKeyboard(["ctrl+tab"], () => setToolbarOpen((open) => !open));
|
|
154
154
|
}, [boundKeyboard]);
|
|
155
|
+
// Keep the coalescing window in step with the persisted setting; edits
|
|
156
|
+
// closer together than it share one undo step (0 disables merging).
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
controller.history.setMergeWindow(settings.history.mergeWindow);
|
|
159
|
+
}, [controller, settings.history.mergeWindow]);
|
|
155
160
|
useEffect(() => {
|
|
156
161
|
const removeWildcard = enableWildcardPriority();
|
|
157
162
|
const unbinds = [];
|
|
@@ -258,6 +263,9 @@ export function Editor({ value: initialText = "", lineNumberRightSpacing = 1, cu
|
|
|
258
263
|
bind([":"], () => openCommandBar(), { mode: "normal" });
|
|
259
264
|
// Ctrl+S saves in normal mode (Vim-style; insert stays untouched).
|
|
260
265
|
bind(["ctrl+s"], () => session.save(), { mode: "normal" });
|
|
266
|
+
// Undo/redo also live in normal mode; insert stays untouched.
|
|
267
|
+
bind(["u"], () => controller.execute("history.undo"), { mode: "normal" });
|
|
268
|
+
bind(["ctrl+r"], () => controller.execute("history.redo"), { mode: "normal" });
|
|
261
269
|
return () => {
|
|
262
270
|
removeWildcard();
|
|
263
271
|
unbinds.forEach((fn) => fn());
|
|
@@ -2,9 +2,10 @@ import { useI18n } from "@cartridge-engine/i18n";
|
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
3
|
import { back, ModalLayerElementContext, useKeyboard, useMouseRegion, useScreenSystem, } from "ink-cartridge";
|
|
4
4
|
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react";
|
|
5
|
+
import { MERGE_WINDOW_MAX, MERGE_WINDOW_MIN, MERGE_WINDOW_STEP, } from "../../core/settings/schema.js";
|
|
5
6
|
import { useSettings } from "../../core/settings/useSettings.js";
|
|
6
7
|
import { ModalFrame } from "../utils/modal-frame.js";
|
|
7
|
-
import { SensitivityBar, snapSensitivity } from "../utils/sensitivity-bar.js";
|
|
8
|
+
import { SensitivityBar, Slider, snapSensitivity, snapValue, } from "../utils/sensitivity-bar.js";
|
|
8
9
|
const LANGUAGES = [
|
|
9
10
|
{ code: "en", labelKey: "settings.language.en" },
|
|
10
11
|
{ code: "zh", labelKey: "settings.language.zh" },
|
|
@@ -96,6 +97,42 @@ function SensitivityPicker({ sensitivityKey }) {
|
|
|
96
97
|
value.toFixed(1),
|
|
97
98
|
"\u00D7")));
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* Modal slider for the undo merge window. The bar is drag/click driven;
|
|
102
|
+
* dragging updates in memory only, release/click persists. `0` disables
|
|
103
|
+
* merging, so the label reads "off" instead of the bare number.
|
|
104
|
+
*/
|
|
105
|
+
function MergeWindowPicker() {
|
|
106
|
+
const ctx = useContext(ModalLayerElementContext);
|
|
107
|
+
const { t } = useI18n();
|
|
108
|
+
const { boundKeyboard } = useKeyboard();
|
|
109
|
+
const { closeModalLayer } = useScreenSystem();
|
|
110
|
+
const { settings, setMergeWindow, setMergeWindowDraft, commit } = useSettings();
|
|
111
|
+
const value = settings.history.mergeWindow;
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!ctx) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const snap = (dir) => snapValue(value, dir, MERGE_WINDOW_MIN, MERGE_WINDOW_MAX, MERGE_WINDOW_STEP);
|
|
117
|
+
const unbinds = [
|
|
118
|
+
boundKeyboard(["left"], () => setMergeWindow(snap(-1)), {
|
|
119
|
+
elementId: ctx.id,
|
|
120
|
+
}),
|
|
121
|
+
boundKeyboard(["right"], () => setMergeWindow(snap(1)), {
|
|
122
|
+
elementId: ctx.id,
|
|
123
|
+
}),
|
|
124
|
+
boundKeyboard(["escape"], () => closeModalLayer(ctx.modalLayer.layerId), { elementId: ctx.id }),
|
|
125
|
+
];
|
|
126
|
+
return () => unbinds.forEach((fn) => fn());
|
|
127
|
+
}, [boundKeyboard, closeModalLayer, ctx, setMergeWindow, value]);
|
|
128
|
+
return (React.createElement(ModalFrame, { title: t("settings.mergeWindow"), footer: React.createElement(Text, { dimColor: true },
|
|
129
|
+
t("settings.back"),
|
|
130
|
+
" (Esc)") },
|
|
131
|
+
React.createElement(Slider, { value: value, min: MERGE_WINDOW_MIN, max: MERGE_WINDOW_MAX, step: MERGE_WINDOW_STEP, onChange: setMergeWindowDraft, onCommit: commit, hint: t("settings.slider.hint") }),
|
|
132
|
+
React.createElement(Text, null, value === MERGE_WINDOW_MIN
|
|
133
|
+
? t("settings.mergeWindow.off")
|
|
134
|
+
: `${value} ms`)));
|
|
135
|
+
}
|
|
99
136
|
/** Small clickable button used inside the file-tree-root picker. */
|
|
100
137
|
function PickerButton({ label, active, onEnter, onLeave, onClick }) {
|
|
101
138
|
const ref = useMouseRegion({ onEnter, onLeave, onClick }, { priority: 1 });
|
|
@@ -221,6 +258,13 @@ export function SettingsEntries({ onExit }) {
|
|
|
221
258
|
element: FileTreePicker,
|
|
222
259
|
});
|
|
223
260
|
}, [applyElementToModalLayer, openModalLayer]);
|
|
261
|
+
const openMergeWindowPicker = useCallback(() => {
|
|
262
|
+
openModalLayer("merge-window", 50);
|
|
263
|
+
applyElementToModalLayer("merge-window", {
|
|
264
|
+
elementId: "merge-window-picker",
|
|
265
|
+
element: MergeWindowPicker,
|
|
266
|
+
});
|
|
267
|
+
}, [applyElementToModalLayer, openModalLayer]);
|
|
224
268
|
const currentLabel = LANGUAGES.find((lang) => lang.code === currentLanguage)?.labelKey ??
|
|
225
269
|
"settings.language.en";
|
|
226
270
|
const entries = useMemo(() => [
|
|
@@ -250,6 +294,14 @@ export function SettingsEntries({ onExit }) {
|
|
|
250
294
|
: t("settings.fileTreeRoot.startup"),
|
|
251
295
|
open: openFileTreePicker,
|
|
252
296
|
},
|
|
297
|
+
{
|
|
298
|
+
id: "merge-window",
|
|
299
|
+
label: t("settings.mergeWindow"),
|
|
300
|
+
value: settings.history.mergeWindow === MERGE_WINDOW_MIN
|
|
301
|
+
? t("settings.mergeWindow.off")
|
|
302
|
+
: `${settings.history.mergeWindow} ms`,
|
|
303
|
+
open: openMergeWindowPicker,
|
|
304
|
+
},
|
|
253
305
|
], [
|
|
254
306
|
t,
|
|
255
307
|
currentLabel,
|
|
@@ -257,9 +309,11 @@ export function SettingsEntries({ onExit }) {
|
|
|
257
309
|
settings.wheel.view,
|
|
258
310
|
settings.fileTree.root,
|
|
259
311
|
settings.fileTree.customPath,
|
|
312
|
+
settings.history.mergeWindow,
|
|
260
313
|
openLanguagePicker,
|
|
261
314
|
openSensitivityPicker,
|
|
262
315
|
openFileTreePicker,
|
|
316
|
+
openMergeWindowPicker,
|
|
263
317
|
]);
|
|
264
318
|
useEffect(() => {
|
|
265
319
|
const unbinds = [
|
|
@@ -2,23 +2,57 @@ import React from "react";
|
|
|
2
2
|
export declare const SENSITIVITY_MIN = 1;
|
|
3
3
|
export declare const SENSITIVITY_MAX = 10;
|
|
4
4
|
export declare const SENSITIVITY_STEP = 0.5;
|
|
5
|
-
/**
|
|
6
|
-
export declare
|
|
7
|
-
/** Map a 0..1 ratio to a
|
|
8
|
-
export declare function valueFromRatio(ratio: number): number;
|
|
5
|
+
/** Number of steps a `min..max` range of `step` size spans. */
|
|
6
|
+
export declare function stepsFor(min: number, max: number, step: number): number;
|
|
7
|
+
/** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
|
|
8
|
+
export declare function valueFromRatio(ratio: number, min?: number, max?: number, step?: number): number;
|
|
9
9
|
/** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
|
|
10
10
|
export declare function snapSensitivity(value: number, dir: 1 | -1): number;
|
|
11
|
-
|
|
11
|
+
/** Clamp value + dir*step to an arbitrary `min..max` range. */
|
|
12
|
+
export declare function snapValue(value: number, dir: 1 | -1, min: number, max: number, step: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* Value for a click on cell `localX` (0-based) of a bar that renders exactly
|
|
15
|
+
* `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
|
|
16
|
+
* therefore runs `localX / steps`, which is exactly one value per cell; using
|
|
17
|
+
* `steps` cells instead would have to skip an interior value to still reach
|
|
18
|
+
* `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
|
|
19
|
+
*/
|
|
20
|
+
export declare function valueFromBarX(localX: number, min: number, max: number, step: number): number;
|
|
21
|
+
/**
|
|
22
|
+
* Filled-cell count for `value` on the `min..max` bar, matching
|
|
23
|
+
* {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
|
|
24
|
+
* cell is filled and everything left of it too — one cell at `min`, the whole
|
|
25
|
+
* bar at `max`. Deriving the count from that integer cell index — rather than
|
|
26
|
+
* from the value's fraction of the range — keeps the fill aligned with the
|
|
27
|
+
* click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
|
|
28
|
+
* skips a cell at the midpoint.
|
|
29
|
+
*/
|
|
30
|
+
export declare function filledCells(value: number, min: number, max: number, step: number): number;
|
|
31
|
+
type SliderProps = {
|
|
12
32
|
value: number;
|
|
33
|
+
min: number;
|
|
34
|
+
max: number;
|
|
35
|
+
step: number;
|
|
13
36
|
/** Called on click/drag with the new value (in-memory draft). */
|
|
14
37
|
onChange: (value: number) => void;
|
|
15
38
|
/** Called when the interaction ends — persist the draft. */
|
|
16
39
|
onCommit: () => void;
|
|
40
|
+
/** Localized hint rendered under the bar. */
|
|
41
|
+
hint: string;
|
|
17
42
|
};
|
|
18
43
|
/**
|
|
19
|
-
* Mouse-driven
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
44
|
+
* Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
|
|
45
|
+
* dragging anywhere on the bar sets the value proportionally, snapped to
|
|
46
|
+
* `step`; the fuller the bar, the higher the value.
|
|
22
47
|
*/
|
|
48
|
+
export declare function Slider({ value, min, max, step, onChange, onCommit, hint }: SliderProps): React.JSX.Element;
|
|
49
|
+
type SensitivityBarProps = {
|
|
50
|
+
value: number;
|
|
51
|
+
/** Called on click/drag with the new value (in-memory draft). */
|
|
52
|
+
onChange: (value: number) => void;
|
|
53
|
+
/** Called when the interaction ends — persist the draft. */
|
|
54
|
+
onCommit: () => void;
|
|
55
|
+
};
|
|
56
|
+
/** Slider preset for the 1..10 wheel sensitivity range. */
|
|
23
57
|
export declare function SensitivityBar({ value, onChange, onCommit }: SensitivityBarProps): React.JSX.Element;
|
|
24
58
|
export {};
|
|
@@ -5,25 +5,57 @@ import React from "react";
|
|
|
5
5
|
export const SENSITIVITY_MIN = 1;
|
|
6
6
|
export const SENSITIVITY_MAX = 10;
|
|
7
7
|
export const SENSITIVITY_STEP = 0.5;
|
|
8
|
-
/**
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
/** Number of steps a `min..max` range of `step` size spans. */
|
|
9
|
+
export function stepsFor(min, max, step) {
|
|
10
|
+
return Math.round((max - min) / step);
|
|
11
|
+
}
|
|
12
|
+
/** Map a 0..1 ratio to a value snapped to `step`, clamped to `min..max`. */
|
|
13
|
+
export function valueFromRatio(ratio, min = SENSITIVITY_MIN, max = SENSITIVITY_MAX, step = SENSITIVITY_STEP) {
|
|
12
14
|
const r = Math.max(0, Math.min(1, ratio));
|
|
13
|
-
|
|
14
|
-
return SENSITIVITY_MIN + Math.round(r * BAR_WIDTH) * SENSITIVITY_STEP;
|
|
15
|
+
return min + Math.round(r * stepsFor(min, max, step)) * step;
|
|
15
16
|
}
|
|
16
17
|
/** Clamp value + dir*step to the valid 1..10 range (0.5 steps). */
|
|
17
18
|
export function snapSensitivity(value, dir) {
|
|
18
|
-
return
|
|
19
|
+
return snapValue(value, dir, SENSITIVITY_MIN, SENSITIVITY_MAX, SENSITIVITY_STEP);
|
|
20
|
+
}
|
|
21
|
+
/** Clamp value + dir*step to an arbitrary `min..max` range. */
|
|
22
|
+
export function snapValue(value, dir, min, max, step) {
|
|
23
|
+
return Math.min(max, Math.max(min, value + dir * step));
|
|
19
24
|
}
|
|
20
25
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* Value for a click on cell `localX` (0-based) of a bar that renders exactly
|
|
27
|
+
* `steps + 1` cells — one per value from `min` to `max` inclusive. The ratio
|
|
28
|
+
* therefore runs `localX / steps`, which is exactly one value per cell; using
|
|
29
|
+
* `steps` cells instead would have to skip an interior value to still reach
|
|
30
|
+
* `max` (e.g. 1000 ms, or 5.5×, would be unreachable by mouse).
|
|
24
31
|
*/
|
|
25
|
-
export function
|
|
26
|
-
const
|
|
32
|
+
export function valueFromBarX(localX, min, max, step) {
|
|
33
|
+
const steps = stepsFor(min, max, step);
|
|
34
|
+
// A zero-width bar (single value) can only mean `min`; avoid /0.
|
|
35
|
+
return steps <= 0 ? min : valueFromRatio(localX / steps, min, max, step);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Filled-cell count for `value` on the `min..max` bar, matching
|
|
39
|
+
* {@link valueFromBarX}: cell `i` selects `min + i*step`, so the value's own
|
|
40
|
+
* cell is filled and everything left of it too — one cell at `min`, the whole
|
|
41
|
+
* bar at `max`. Deriving the count from that integer cell index — rather than
|
|
42
|
+
* from the value's fraction of the range — keeps the fill aligned with the
|
|
43
|
+
* click mapping: a proportional `fraction * (steps + 1)` rounds unevenly and
|
|
44
|
+
* skips a cell at the midpoint.
|
|
45
|
+
*/
|
|
46
|
+
export function filledCells(value, min, max, step) {
|
|
47
|
+
const total = stepsFor(min, max, step) + 1;
|
|
48
|
+
const index = Math.round((value - min) / step);
|
|
49
|
+
return Math.min(total, Math.max(1, index + 1));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Mouse-driven slider over an arbitrary `min..max`/`step` range. Clicking or
|
|
53
|
+
* dragging anywhere on the bar sets the value proportionally, snapped to
|
|
54
|
+
* `step`; the fuller the bar, the higher the value.
|
|
55
|
+
*/
|
|
56
|
+
export function Slider({ value, min, max, step, onChange, onCommit, hint }) {
|
|
57
|
+
const steps = stepsFor(min, max, step);
|
|
58
|
+
const valueFromX = (localX) => valueFromBarX(localX, min, max, step);
|
|
27
59
|
// priority 1: the bar sits inside the draggable ModalFrame, whose region
|
|
28
60
|
// overlaps it — the child control must always win the hit test, no matter
|
|
29
61
|
// the registration order after frame drags.
|
|
@@ -36,14 +68,18 @@ export function SensitivityBar({ value, onChange, onCommit }) {
|
|
|
36
68
|
onDragMove: (event, rect) => onChange(valueFromX(event.x - rect.x)),
|
|
37
69
|
onDragEnd: () => onCommit(),
|
|
38
70
|
}, { priority: 1 });
|
|
39
|
-
|
|
71
|
+
// One cell per value (min..max inclusive); the fill is derived from the
|
|
72
|
+
// value's own cell, so it matches what a click on that cell would select.
|
|
73
|
+
const total = steps + 1;
|
|
74
|
+
const filled = filledCells(value, min, max, step);
|
|
40
75
|
return (React.createElement(Box, { flexDirection: "column", alignItems: "center", gap: 1 },
|
|
41
76
|
React.createElement(Box, { ref: ref, flexDirection: "row" },
|
|
42
77
|
React.createElement(Text, null, filled > 0 ? "█".repeat(filled) : ""),
|
|
43
|
-
React.createElement(Text, { dimColor: true }, "░".repeat(
|
|
44
|
-
React.createElement(Text, { dimColor: true },
|
|
78
|
+
React.createElement(Text, { dimColor: true }, "░".repeat(total - filled))),
|
|
79
|
+
React.createElement(Text, { dimColor: true }, hint)));
|
|
45
80
|
}
|
|
46
|
-
/**
|
|
47
|
-
function
|
|
48
|
-
|
|
81
|
+
/** Slider preset for the 1..10 wheel sensitivity range. */
|
|
82
|
+
export function SensitivityBar({ value, onChange, onCommit }) {
|
|
83
|
+
const { t } = useI18n();
|
|
84
|
+
return (React.createElement(Slider, { value: value, min: SENSITIVITY_MIN, max: SENSITIVITY_MAX, step: SENSITIVITY_STEP, onChange: onChange, onCommit: onCommit, hint: t("settings.slider.hint") }));
|
|
49
85
|
}
|