ompchamber 0.7.0 → 0.8.0
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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.5c0447cefe.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/{g.86e2256a70.js → g.d1c2fe09aa.js} +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +1 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/hooks/chat/omp/session-list.tsx +4 -0
- package/src/client/hooks/chat/timeline/actions.ts +0 -9
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +6 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/tailwind.css +38 -12
- package/src/server/lib/omp/rpc/manager.ts +14 -3
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/types/omp/agent.ts +2 -2
- package/src/shared/types/workspace.ts +4 -0
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ompchamber",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Developer web console and diagnostic chamber for AI Oh-My-Pi — Elysia + Preact on Bun.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
|
|
@@ -14,13 +14,16 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import type { TargetedMouseEvent } from 'preact';
|
|
17
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks';
|
|
17
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
18
18
|
import { renderMarkdown } from '@/shared/lib/markdown/marked';
|
|
19
19
|
import { hydrateMathBlocks, MATH_PENDING_CLASS, preloadKatex } from '@/shared/lib/markdown/katex';
|
|
20
20
|
import { sanitizeHtml } from '@/shared/lib/markdown/sanitize';
|
|
21
21
|
import { hydrateMermaidBlocks } from '@/shared/lib/markdown/mermaid';
|
|
22
22
|
import { copyToClipboard } from '@/client/hooks/ui/clipboard';
|
|
23
23
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
24
|
+
import { DiagramViewer } from '@/client/components/common/diagram-viewer';
|
|
25
|
+
import { readDiagramBlock, type DiagramSnapshot } from '@/client/components/common/diagram-viewer/read-block';
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
26
29
|
|
|
@@ -31,6 +34,9 @@ interface MarkdownRendererProps {
|
|
|
31
34
|
|
|
32
35
|
export function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
|
|
33
36
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
37
|
+
/** Block the open viewer was read from, so a theme re-render can refresh it. */
|
|
38
|
+
const diagramBlockRef = useRef<HTMLElement | null>(null);
|
|
39
|
+
const [diagram, setDiagram] = useState<DiagramSnapshot | null>(null);
|
|
34
40
|
|
|
35
41
|
const syntaxReady = useSyntaxReady();
|
|
36
42
|
|
|
@@ -83,13 +89,13 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr
|
|
|
83
89
|
if (!container) return;
|
|
84
90
|
|
|
85
91
|
let timer: number | undefined;
|
|
86
|
-
const hydrate = () => {
|
|
92
|
+
const hydrate = async () => {
|
|
87
93
|
const current = containerRef.current;
|
|
88
94
|
if (!current || !current.isConnected) return;
|
|
89
|
-
|
|
95
|
+
await hydrateMermaidBlocks(current);
|
|
90
96
|
};
|
|
91
97
|
|
|
92
|
-
hydrate();
|
|
98
|
+
void hydrate();
|
|
93
99
|
// React can rewrite the container's innerHTML with an identical html
|
|
94
100
|
// string (timeline re-renders) without this effect re-running, which
|
|
95
101
|
// would strand freshly-inserted pending blocks. Re-hydrate on any
|
|
@@ -99,45 +105,81 @@ export function MarkdownRenderer({ content, className = '' }: MarkdownRendererPr
|
|
|
99
105
|
const current = containerRef.current;
|
|
100
106
|
if (current?.isConnected) void hydrateMermaidBlocks(current, { cachedOnly: true });
|
|
101
107
|
window.clearTimeout(timer);
|
|
102
|
-
timer = window.setTimeout(hydrate, 120);
|
|
108
|
+
timer = window.setTimeout(() => void hydrate(), 120);
|
|
103
109
|
});
|
|
104
110
|
observer.observe(container, { childList: true });
|
|
105
111
|
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
// Diagrams bake the palette into their SVG, so every theme write must
|
|
113
|
+
// re-render them. The trigger is the `data-theme` attribute itself rather
|
|
114
|
+
// than the `omp:theme-changed` event: the navbar toggle and the settings
|
|
115
|
+
// modal are both writers, and observing the attribute catches all of them
|
|
116
|
+
// the moment the document changes. Vacuously cheap for a same-palette write
|
|
117
|
+
// (blocks already matching the new theme key are skipped).
|
|
118
|
+
const rehydrateForTheme = async () => {
|
|
119
|
+
await hydrate();
|
|
120
|
+
const block = diagramBlockRef.current;
|
|
121
|
+
// A timeline re-render can replace the block under an open viewer; keep
|
|
122
|
+
// the last snapshot then rather than closing the viewer on the user.
|
|
123
|
+
if (!block?.isConnected) return;
|
|
124
|
+
const snapshot = readDiagramBlock(block);
|
|
125
|
+
if (snapshot) setDiagram(snapshot);
|
|
126
|
+
};
|
|
127
|
+
const themeObserver = new MutationObserver(() => void rehydrateForTheme());
|
|
128
|
+
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
|
|
129
|
+
|
|
108
130
|
return () => {
|
|
109
131
|
window.clearTimeout(timer);
|
|
110
132
|
observer.disconnect();
|
|
111
|
-
|
|
133
|
+
themeObserver.disconnect();
|
|
112
134
|
};
|
|
113
135
|
}, [hasMermaid, html]);
|
|
114
136
|
|
|
115
137
|
const handleContainerClick = useCallback((e: TargetedMouseEvent<HTMLDivElement>) => {
|
|
116
138
|
const target = (e.target as HTMLElement).closest<HTMLButtonElement>('button.code-copy-float');
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
if (target) {
|
|
140
|
+
const text = target.dataset.copy ?? '';
|
|
141
|
+
if (!text) return;
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
e.stopPropagation();
|
|
144
|
+
void copyToClipboard(text).then((ok) => {
|
|
145
|
+
if (!ok) return;
|
|
146
|
+
target.setAttribute('data-copied', 'true');
|
|
147
|
+
target.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
target.removeAttribute('data-copied');
|
|
150
|
+
target.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
151
|
+
}, 2000);
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A rendered diagram is a door into the zoom/pan viewer; pending, failed,
|
|
157
|
+
// and still-streaming blocks stay inert.
|
|
158
|
+
const block = (e.target as HTMLElement).closest<HTMLElement>('.mermaid-block[data-mermaid-state="done"]');
|
|
159
|
+
if (!block) return;
|
|
160
|
+
const snapshot = readDiagramBlock(block);
|
|
161
|
+
if (!snapshot) return;
|
|
120
162
|
e.preventDefault();
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
target.innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
|
|
129
|
-
}, 2000);
|
|
130
|
-
});
|
|
163
|
+
diagramBlockRef.current = block;
|
|
164
|
+
setDiagram(snapshot);
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
167
|
+
const closeDiagram = useCallback(() => {
|
|
168
|
+
diagramBlockRef.current = null;
|
|
169
|
+
setDiagram(null);
|
|
131
170
|
}, []);
|
|
132
171
|
|
|
133
172
|
if (!content) return null;
|
|
134
173
|
|
|
135
174
|
return (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
175
|
+
<>
|
|
176
|
+
<div
|
|
177
|
+
ref={containerRef}
|
|
178
|
+
className={`prose-content text-[13px] text-ink leading-relaxed select-text ${className}`}
|
|
179
|
+
onClick={handleContainerClick}
|
|
180
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
181
|
+
/>
|
|
182
|
+
{diagram && <DiagramViewer {...diagram} onClose={closeDiagram} />}
|
|
183
|
+
</>
|
|
142
184
|
);
|
|
143
185
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
continuesTyping,
|
|
10
|
+
createHistory,
|
|
11
|
+
mergeTyping,
|
|
12
|
+
pushEdit,
|
|
13
|
+
pushRecord,
|
|
14
|
+
stepHistory,
|
|
15
|
+
HISTORY_TIME_GAP,
|
|
16
|
+
type HistoryRecord,
|
|
17
|
+
} from '@/client/components/common/code-editor/history';
|
|
18
|
+
|
|
19
|
+
const NOW = 1_700_000_000_000;
|
|
20
|
+
|
|
21
|
+
function record(value: string, caret: number, timestamp = NOW): HistoryRecord {
|
|
22
|
+
return { value, selectionStart: caret, selectionEnd: caret, timestamp };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('pushRecord', () => {
|
|
26
|
+
test('appends and moves the current position', () => {
|
|
27
|
+
const history = createHistory();
|
|
28
|
+
pushRecord(history, record('a', 1));
|
|
29
|
+
pushRecord(history, record('ab', 2));
|
|
30
|
+
|
|
31
|
+
expect(history.stack.map((entry) => entry.value)).toEqual(['a', 'ab']);
|
|
32
|
+
expect(history.offset).toBe(1);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('drops the redo entries that follow the current position', () => {
|
|
36
|
+
const history = createHistory();
|
|
37
|
+
pushRecord(history, record('a', 1));
|
|
38
|
+
pushRecord(history, record('ab', 2));
|
|
39
|
+
history.offset = 0;
|
|
40
|
+
|
|
41
|
+
pushRecord(history, record('ax', 2));
|
|
42
|
+
|
|
43
|
+
expect(history.stack.map((entry) => entry.value)).toEqual(['a', 'ax']);
|
|
44
|
+
expect(history.offset).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('pushEdit', () => {
|
|
49
|
+
test('caps the stack and keeps the position on the newest entry', () => {
|
|
50
|
+
const history = createHistory();
|
|
51
|
+
for (let i = 0; i < 120; i++) pushEdit(history, record(`v${i}`, 0, NOW + i));
|
|
52
|
+
|
|
53
|
+
expect(history.stack.length).toBe(100);
|
|
54
|
+
expect(history.stack[history.offset].value).toBe('v119');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('continuesTyping', () => {
|
|
59
|
+
const previous = record('const value = 1', 15);
|
|
60
|
+
|
|
61
|
+
test('merges a keystroke that keeps extending the same word', () => {
|
|
62
|
+
expect(continuesTyping(previous, 'const value = 12', 16, NOW + 100)).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('starts a new entry once the burst window has passed', () => {
|
|
66
|
+
expect(continuesTyping(previous, 'const value = 12', 16, NOW + HISTORY_TIME_GAP + 1)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('does not merge a different word or a fresh word', () => {
|
|
70
|
+
expect(continuesTyping(record('const value', 11), 'const other', 11, NOW + 100)).toBe(false);
|
|
71
|
+
expect(continuesTyping(undefined, 'const value = 12', 16, NOW + 100)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('mergeTyping', () => {
|
|
76
|
+
test('rewrites the current entry in place', () => {
|
|
77
|
+
const history = createHistory();
|
|
78
|
+
pushRecord(history, record('a', 1));
|
|
79
|
+
mergeTyping(history, 'ab', 2, 2, NOW + 50);
|
|
80
|
+
|
|
81
|
+
expect(history.stack).toHaveLength(1);
|
|
82
|
+
expect(history.stack[0].value).toBe('ab');
|
|
83
|
+
expect(history.stack[0].selectionStart).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('invalidates the redo entries after the current position', () => {
|
|
87
|
+
const history = createHistory();
|
|
88
|
+
pushRecord(history, record('a', 1));
|
|
89
|
+
pushRecord(history, record('ab', 2));
|
|
90
|
+
history.offset = 0;
|
|
91
|
+
|
|
92
|
+
mergeTyping(history, 'ax', 2, 2, NOW + 50);
|
|
93
|
+
|
|
94
|
+
expect(history.stack.map((entry) => entry.value)).toEqual(['ax']);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('stepHistory', () => {
|
|
99
|
+
test('walks back and forward through the stack', () => {
|
|
100
|
+
const history = createHistory();
|
|
101
|
+
pushRecord(history, record('a', 1));
|
|
102
|
+
pushRecord(history, record('ab', 2));
|
|
103
|
+
|
|
104
|
+
expect(stepHistory(history, 'undo')?.value).toBe('a');
|
|
105
|
+
expect(history.offset).toBe(0);
|
|
106
|
+
expect(stepHistory(history, 'redo')?.value).toBe('ab');
|
|
107
|
+
expect(history.offset).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('stops at both ends without moving', () => {
|
|
111
|
+
const history = createHistory();
|
|
112
|
+
pushRecord(history, record('a', 1));
|
|
113
|
+
|
|
114
|
+
expect(stepHistory(history, 'redo')).toBeNull();
|
|
115
|
+
expect(stepHistory(history, 'undo')).toBeNull();
|
|
116
|
+
expect(history.offset).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Undo history for the code editor.
|
|
8
|
+
*
|
|
9
|
+
* The editor cannot lean on the browser's own undo: it renders a transparent
|
|
10
|
+
* `<textarea>` over a highlighted `<pre>`, so a native undo would desync the two
|
|
11
|
+
* layers. Every entry therefore stores the whole buffer plus the caret, and this
|
|
12
|
+
* module is the bookkeeping behind ⌘Z / ⇧⌘Z — push, merge a typing burst into
|
|
13
|
+
* the word being typed, cap the stack, and step through it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface HistoryRecord {
|
|
17
|
+
value: string;
|
|
18
|
+
selectionStart: number;
|
|
19
|
+
selectionEnd: number;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface History {
|
|
24
|
+
stack: HistoryRecord[];
|
|
25
|
+
offset: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const HISTORY_LIMIT = 100;
|
|
29
|
+
/** Typing within this window of the previous edit merges into that entry. */
|
|
30
|
+
export const HISTORY_TIME_GAP = 3000;
|
|
31
|
+
/** The word the caret sits at the end of, which is what a burst keeps extending. */
|
|
32
|
+
const WORD_TAIL = /[^a-z0-9]([a-z0-9]+)$/i;
|
|
33
|
+
|
|
34
|
+
export function createHistory(): History {
|
|
35
|
+
return { stack: [], offset: -1 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Append `record`, dropping the redo entries that follow the current position. */
|
|
39
|
+
export function pushRecord(history: History, record: HistoryRecord): void {
|
|
40
|
+
if (history.stack.length && history.offset > -1) {
|
|
41
|
+
history.stack = history.stack.slice(0, history.offset + 1);
|
|
42
|
+
}
|
|
43
|
+
history.stack.push(record);
|
|
44
|
+
history.offset = history.stack.length - 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Push an edit and cap the stack, leaving `offset` on the same entry. */
|
|
48
|
+
export function pushEdit(history: History, record: HistoryRecord): void {
|
|
49
|
+
pushRecord(history, record);
|
|
50
|
+
if (history.stack.length <= HISTORY_LIMIT) return;
|
|
51
|
+
const extras = history.stack.length - HISTORY_LIMIT;
|
|
52
|
+
history.stack = history.stack.slice(extras);
|
|
53
|
+
history.offset = Math.max(history.offset - extras, 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The tail word of the line `position` sits on. */
|
|
57
|
+
function tailWord(value: string, position: number): string | undefined {
|
|
58
|
+
return value.substring(0, position).split('\n').pop()?.match(WORD_TAIL)?.[1];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether a change keeps typing the word the previous entry ended with — the
|
|
63
|
+
* test that folds a run of keystrokes into one undoable edit.
|
|
64
|
+
*/
|
|
65
|
+
export function continuesTyping(
|
|
66
|
+
last: HistoryRecord | undefined,
|
|
67
|
+
next: string,
|
|
68
|
+
selectionStart: number,
|
|
69
|
+
timestamp: number,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (!last || timestamp - last.timestamp >= HISTORY_TIME_GAP) return false;
|
|
72
|
+
const previous = tailWord(last.value, last.selectionStart);
|
|
73
|
+
const current = tailWord(next, selectionStart);
|
|
74
|
+
return Boolean(previous && current?.startsWith(previous));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Fold a continuation into the current entry, which stays the current position. */
|
|
78
|
+
export function mergeTyping(
|
|
79
|
+
history: History,
|
|
80
|
+
next: string,
|
|
81
|
+
selectionStart: number,
|
|
82
|
+
selectionEnd: number,
|
|
83
|
+
timestamp: number,
|
|
84
|
+
): void {
|
|
85
|
+
// A merge is an edit like any other, so it drops the redo entries after it —
|
|
86
|
+
// otherwise typing straight after an undo would leave a stale redo target.
|
|
87
|
+
history.stack = history.stack.slice(0, history.offset + 1);
|
|
88
|
+
history.stack[history.offset] = { value: next, selectionStart, selectionEnd, timestamp };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Step one entry back (undo) or forward (redo) and return the buffer to
|
|
93
|
+
* restore, or null at either end of the stack.
|
|
94
|
+
*/
|
|
95
|
+
export function stepHistory(history: History, direction: 'undo' | 'redo'): HistoryRecord | null {
|
|
96
|
+
const index = direction === 'undo' ? history.offset - 1 : history.offset + 1;
|
|
97
|
+
const record = history.stack[index];
|
|
98
|
+
if (!record) return null;
|
|
99
|
+
history.offset = direction === 'undo' ? Math.max(index, 0) : Math.min(index, history.stack.length - 1);
|
|
100
|
+
return record;
|
|
101
|
+
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
continuesTyping,
|
|
6
|
+
createHistory,
|
|
7
|
+
mergeTyping,
|
|
8
|
+
pushEdit,
|
|
9
|
+
pushRecord,
|
|
10
|
+
stepHistory,
|
|
11
|
+
type HistoryRecord,
|
|
12
|
+
} from '@/client/components/common/code-editor/history';
|
|
13
|
+
import type { CodeWindow, LineWindow } from '@/shared/lib/code/lazy-window';
|
|
14
|
+
|
|
4
15
|
/**
|
|
5
16
|
* Preact hand-roll of the textarea-over-<pre> highlighting editor
|
|
6
17
|
* (the pattern react-simple-code-editor popularized), trimmed to the
|
|
@@ -9,28 +20,18 @@ import type { CSSProperties, Ref, TargetedKeyboardEvent } from 'preact';
|
|
|
9
20
|
* How it works: a transparent <textarea> sits on top of a syntax-highlighted
|
|
10
21
|
* <pre>. Typing, selecting and copying hit the native textarea; the <pre>
|
|
11
22
|
* below just mirrors the text with Shiki markup.
|
|
23
|
+
*
|
|
24
|
+
* For a long document only a window of lines is mirrored (`virtual`): the
|
|
25
|
+
* textarea still holds every line, so editing, selection and copy are
|
|
26
|
+
* untouched, while the `<pre>` carries just the visible ones and reserves the
|
|
27
|
+
* rest as padding — the alignment that keeps the caret over its own row.
|
|
12
28
|
*/
|
|
13
29
|
|
|
14
|
-
const HISTORY_LIMIT = 100;
|
|
15
|
-
const HISTORY_TIME_GAP = 3000;
|
|
16
|
-
|
|
17
|
-
interface HistoryRecord {
|
|
18
|
-
value: string;
|
|
19
|
-
selectionStart: number;
|
|
20
|
-
selectionEnd: number;
|
|
21
|
-
timestamp: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface History {
|
|
25
|
-
stack: HistoryRecord[];
|
|
26
|
-
offset: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
30
|
export interface CodeEditorProps {
|
|
30
31
|
value: string;
|
|
31
32
|
onValueChange: (value: string) => void;
|
|
32
|
-
/** Return the Shiki-highlighted HTML for the
|
|
33
|
-
highlight: (code: string) => string;
|
|
33
|
+
/** Return the Shiki-highlighted HTML for `code`, limited to `range` when the surface is windowed. */
|
|
34
|
+
highlight: (code: string, range: LineWindow | null) => string;
|
|
34
35
|
padding?: number;
|
|
35
36
|
/** Class applied to the inner <pre> holding the highlighted code. */
|
|
36
37
|
preClassName?: string;
|
|
@@ -44,6 +45,8 @@ export interface CodeEditorProps {
|
|
|
44
45
|
placeholder?: string;
|
|
45
46
|
readOnly?: boolean;
|
|
46
47
|
autoFocus?: boolean;
|
|
48
|
+
/** Renders only this window of lines, with the rest reserved as space. Omit to mirror the whole value. */
|
|
49
|
+
virtual?: CodeWindow | null;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
const BASE_TEXTAREA_CLASS = 'code-editor-native-textarea';
|
|
@@ -80,9 +83,10 @@ export function CodeEditor({
|
|
|
80
83
|
placeholder,
|
|
81
84
|
readOnly,
|
|
82
85
|
autoFocus,
|
|
86
|
+
virtual,
|
|
83
87
|
}: CodeEditorProps) {
|
|
84
88
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
85
|
-
const historyRef = useRef
|
|
89
|
+
const historyRef = useRef(createHistory());
|
|
86
90
|
const [captureTab, setCaptureTab] = useState(true);
|
|
87
91
|
|
|
88
92
|
// Re-record when the controlled value is replaced from the outside
|
|
@@ -90,18 +94,12 @@ export function CodeEditor({
|
|
|
90
94
|
useEffect(() => {
|
|
91
95
|
const input = inputRef.current;
|
|
92
96
|
if (!input) return;
|
|
93
|
-
|
|
97
|
+
pushRecord(historyRef.current, {
|
|
94
98
|
value: input.value,
|
|
95
99
|
selectionStart: input.selectionStart,
|
|
96
100
|
selectionEnd: input.selectionEnd,
|
|
97
101
|
timestamp: Date.now(),
|
|
98
|
-
};
|
|
99
|
-
const { stack, offset } = historyRef.current;
|
|
100
|
-
if (stack.length && offset > -1) {
|
|
101
|
-
historyRef.current.stack = stack.slice(0, offset + 1);
|
|
102
|
-
}
|
|
103
|
-
historyRef.current.stack.push(record);
|
|
104
|
-
historyRef.current.offset = historyRef.current.stack.length - 1;
|
|
102
|
+
});
|
|
105
103
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
106
104
|
}, [value]);
|
|
107
105
|
|
|
@@ -109,19 +107,15 @@ export function CodeEditor({
|
|
|
109
107
|
const input = inputRef.current;
|
|
110
108
|
const last = historyRef.current.stack[historyRef.current.offset];
|
|
111
109
|
if (last && input) {
|
|
110
|
+
// The entry on top is about to be superseded: keep where its caret was,
|
|
111
|
+
// so undo returns to the selection this edit replaced.
|
|
112
112
|
historyRef.current.stack[historyRef.current.offset] = {
|
|
113
113
|
...last,
|
|
114
114
|
selectionStart: input.selectionStart,
|
|
115
115
|
selectionEnd: input.selectionEnd,
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
-
historyRef.current
|
|
119
|
-
historyRef.current.offset = historyRef.current.stack.length - 1;
|
|
120
|
-
if (historyRef.current.stack.length > HISTORY_LIMIT) {
|
|
121
|
-
const extras = historyRef.current.stack.length - HISTORY_LIMIT;
|
|
122
|
-
historyRef.current.stack = historyRef.current.stack.slice(extras);
|
|
123
|
-
historyRef.current.offset = Math.max(historyRef.current.offset - extras, 0);
|
|
124
|
-
}
|
|
118
|
+
pushEdit(historyRef.current, { ...record, timestamp: Date.now() });
|
|
125
119
|
onValueChange(record.value);
|
|
126
120
|
if (input) {
|
|
127
121
|
input.value = record.value;
|
|
@@ -220,14 +214,12 @@ export function CodeEditor({
|
|
|
220
214
|
const undoKey = isMac ? e.metaKey && e.key.toLowerCase() === 'z' : e.ctrlKey && e.key.toLowerCase() === 'z';
|
|
221
215
|
if (undoKey && !e.shiftKey && !e.altKey) {
|
|
222
216
|
e.preventDefault();
|
|
223
|
-
const
|
|
224
|
-
const record = stack[offset - 1];
|
|
217
|
+
const record = stepHistory(historyRef.current, 'undo');
|
|
225
218
|
if (record && inputRef.current) {
|
|
226
219
|
inputRef.current.value = record.value;
|
|
227
220
|
inputRef.current.selectionStart = record.selectionStart;
|
|
228
221
|
inputRef.current.selectionEnd = record.selectionEnd;
|
|
229
222
|
onValueChange(record.value);
|
|
230
|
-
historyRef.current.offset = Math.max(offset - 1, 0);
|
|
231
223
|
}
|
|
232
224
|
return;
|
|
233
225
|
}
|
|
@@ -239,14 +231,12 @@ export function CodeEditor({
|
|
|
239
231
|
: e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'z';
|
|
240
232
|
if (redoKey && !e.altKey) {
|
|
241
233
|
e.preventDefault();
|
|
242
|
-
const
|
|
243
|
-
const record = stack[offset + 1];
|
|
234
|
+
const record = stepHistory(historyRef.current, 'redo');
|
|
244
235
|
if (record && inputRef.current) {
|
|
245
236
|
inputRef.current.value = record.value;
|
|
246
237
|
inputRef.current.selectionStart = record.selectionStart;
|
|
247
238
|
inputRef.current.selectionEnd = record.selectionEnd;
|
|
248
239
|
onValueChange(record.value);
|
|
249
|
-
historyRef.current.offset = Math.min(offset + 1, stack.length - 1);
|
|
250
240
|
}
|
|
251
241
|
return;
|
|
252
242
|
}
|
|
@@ -261,37 +251,18 @@ export function CodeEditor({
|
|
|
261
251
|
const handleChange = (e: Event) => {
|
|
262
252
|
const input = e.target as HTMLTextAreaElement;
|
|
263
253
|
const { value: next, selectionStart, selectionEnd } = input;
|
|
264
|
-
const
|
|
254
|
+
const history = historyRef.current;
|
|
265
255
|
const timestamp = Date.now();
|
|
266
|
-
if (stack.length && offset > -1) {
|
|
267
|
-
historyRef.current.stack = stack.slice(0, offset + 1);
|
|
268
|
-
}
|
|
269
|
-
const last = historyRef.current.stack[historyRef.current.offset];
|
|
270
256
|
// Merge typing bursts into one word-level undo entry.
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (previous?.[1] && current?.[1]?.startsWith(previous[1])) {
|
|
276
|
-
historyRef.current.stack[historyRef.current.offset] = { value: next, selectionStart, selectionEnd, timestamp };
|
|
277
|
-
onValueChange(next);
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
257
|
+
if (continuesTyping(history.stack[history.offset], next, selectionStart, timestamp)) {
|
|
258
|
+
mergeTyping(history, next, selectionStart, selectionEnd, timestamp);
|
|
259
|
+
onValueChange(next);
|
|
260
|
+
return;
|
|
280
261
|
}
|
|
281
|
-
|
|
282
|
-
historyRef.current.offset = historyRef.current.stack.length - 1;
|
|
262
|
+
pushRecord(history, { value: next, selectionStart, selectionEnd, timestamp });
|
|
283
263
|
onValueChange(next);
|
|
284
264
|
};
|
|
285
265
|
|
|
286
|
-
const contentStyle: CSSProperties = {
|
|
287
|
-
paddingTop: padding,
|
|
288
|
-
paddingRight: padding,
|
|
289
|
-
paddingBottom: padding,
|
|
290
|
-
paddingLeft: padding,
|
|
291
|
-
};
|
|
292
|
-
|
|
293
|
-
const highlighted = highlight(value);
|
|
294
|
-
|
|
295
266
|
const layerStyle: CSSProperties = {
|
|
296
267
|
margin: 0,
|
|
297
268
|
border: 0,
|
|
@@ -314,14 +285,33 @@ export function CodeEditor({
|
|
|
314
285
|
overflowWrap: 'break-word',
|
|
315
286
|
};
|
|
316
287
|
|
|
288
|
+
// The textarea owns the full document, so its padding is the plain inset and
|
|
289
|
+
// its lines land exactly where the geometry says line 0 starts.
|
|
290
|
+
const contentStyle: CSSProperties = {
|
|
291
|
+
paddingTop: padding,
|
|
292
|
+
paddingRight: padding,
|
|
293
|
+
paddingBottom: padding,
|
|
294
|
+
paddingLeft: padding,
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// The `<pre>` carries only the window; the lines it is not given are reserved
|
|
298
|
+
// as padding, so the aligned layers stay aligned.
|
|
299
|
+
const windowStyle: CSSProperties = {
|
|
300
|
+
...contentStyle,
|
|
301
|
+
paddingTop: padding + (virtual?.top ?? 0),
|
|
302
|
+
paddingBottom: padding + (virtual?.bottom ?? 0),
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const highlighted = highlight(value, virtual ?? null);
|
|
306
|
+
|
|
317
307
|
return (
|
|
318
308
|
<div className={className} style={{ position: 'relative', textAlign: 'left', boxSizing: 'border-box', padding: 0, overflow: 'hidden', ...style }}>
|
|
319
309
|
<pre
|
|
320
310
|
ref={preRef}
|
|
321
311
|
className={preClassName}
|
|
322
312
|
aria-hidden="true"
|
|
323
|
-
style={{ ...layerStyle, ...
|
|
324
|
-
dangerouslySetInnerHTML={{ __html: highlighted + '<br />' }}
|
|
313
|
+
style={{ ...layerStyle, ...windowStyle, position: 'relative', pointerEvents: 'none' }}
|
|
314
|
+
dangerouslySetInnerHTML={{ __html: highlighted + (virtual && !virtual.breakAtEnd ? '' : '<br />') }}
|
|
325
315
|
/>
|
|
326
316
|
<textarea
|
|
327
317
|
ref={inputRef}
|