mrmd-editor 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/package.json +3 -1
- package/src/commands.js +112 -4
- package/src/comment-syntax.js +364 -39
- package/src/config/handlers.js +1 -2
- package/src/config/schema.js +46 -4
- package/src/document-template.js +2236 -0
- package/src/execution.js +69 -15
- package/src/frontmatter-updater.js +204 -74
- package/src/grammar.js +758 -0
- package/src/index.js +1120 -55
- package/src/keymap.js +11 -2
- package/src/markdown/block-decorations.js +108 -5
- package/src/markdown/facets.js +37 -0
- package/src/markdown/html-inline.js +9 -5
- package/src/markdown/index.js +13 -3
- package/src/markdown/inline-commands.js +256 -0
- package/src/markdown/inline-model.js +578 -0
- package/src/markdown/inline-state.js +103 -0
- package/src/markdown/renderer.js +219 -12
- package/src/markdown/styles.js +290 -3
- package/src/markdown/widgets/alert-title.js +10 -8
- package/src/markdown/widgets/frontmatter.js +0 -6
- package/src/markdown/widgets/index.js +1 -0
- package/src/markdown/widgets/list-marker.js +29 -0
- package/src/markdown/wysiwyg.js +1158 -0
- package/src/mrp-types.js +2 -0
- package/src/output-widget.js +532 -18
- package/src/page-view-pagination.js +127 -0
- package/src/runtime-lsp.js +1757 -150
- package/src/section-controls/commands.js +617 -0
- package/src/section-controls/index.js +63 -0
- package/src/section-controls/plugin.js +165 -0
- package/src/section-controls/widgets.js +936 -0
- package/src/shell/ai-menu.js +11 -0
- package/src/shell/components/context-panel.js +572 -0
- package/src/shell/components/status-bar.js +218 -8
- package/src/shell/dialogs/file-picker.js +211 -0
- package/src/shell/layouts/studio.js +229 -14
- package/src/shell/orchestrator-client.js +114 -0
- package/src/shell/styles.js +62 -0
- package/src/spellcheck.js +166 -0
- package/src/tables/README.md +97 -0
- package/src/tables/commands/insert-linked-table.js +122 -0
- package/src/tables/commands/open-table-workspace.js +43 -0
- package/src/tables/index.js +24 -0
- package/src/tables/jobs/client.js +158 -0
- package/src/tables/parsing/anchors.js +82 -0
- package/src/tables/parsing/linked-table-blocks.js +61 -0
- package/src/tables/state/linked-table-state.js +68 -0
- package/src/tables/widgets/linked-table-source-banner.js +77 -0
- package/src/tables/widgets/linked-table-widget.js +256 -0
- package/src/tables/workspace/controller.js +616 -0
- package/src/term-pty-client.js +111 -7
- package/src/term-widget.js +43 -3
- package/src/widgets/theme-utils.js +24 -16
- package/src/widgets/theme.js +1535 -1
- package/src/runtime-codelens/detector.js +0 -279
- package/src/runtime-codelens/index.js +0 -76
- package/src/runtime-codelens/plugin.js +0 -142
- package/src/runtime-codelens/styles.js +0 -184
- package/src/runtime-codelens/widgets.js +0 -216
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Spellcheck / autocorrect for prose in CodeMirror 6
|
|
3
|
+
*
|
|
4
|
+
* Enables the browser's native spellcheck on the CM6 content element, then
|
|
5
|
+
* disables it in places that are usually not natural-language prose:
|
|
6
|
+
* - fenced code blocks
|
|
7
|
+
* - inline code
|
|
8
|
+
* - markdown link URLs
|
|
9
|
+
* - quoted literals like 'foo/bar'
|
|
10
|
+
* - path-like tokens such as ./src/app, /usr/bin, src/utils/file.py
|
|
11
|
+
*
|
|
12
|
+
* Important note about "autocorrect":
|
|
13
|
+
* On desktop Chromium/Electron, what you mostly get is native spellcheck
|
|
14
|
+
* (underlines + suggestions), not aggressive iOS-style auto-replacement.
|
|
15
|
+
* The `autocorrect` attribute is mainly useful in Safari/iOS and is mostly
|
|
16
|
+
* ignored by Chromium. So in Electron this feature is best thought of as
|
|
17
|
+
* fast spellcheck with suggestions, not full mobile-style autocorrect.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { EditorView, Decoration, ViewPlugin } from '@codemirror/view';
|
|
21
|
+
import { syntaxTree } from '@codemirror/language';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Enable browser-native spellcheck on the editor's contenteditable element.
|
|
25
|
+
*/
|
|
26
|
+
const proseSpellcheck = EditorView.contentAttributes.of({
|
|
27
|
+
spellcheck: 'true',
|
|
28
|
+
autocorrect: 'on', // Safari / iOS; harmless elsewhere
|
|
29
|
+
autocapitalize: 'sentences', // mobile keyboards
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reused mark decoration for ranges where spellcheck should be disabled.
|
|
34
|
+
*/
|
|
35
|
+
const noSpellcheckMark = Decoration.mark({
|
|
36
|
+
attributes: { spellcheck: 'false' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Markdown syntax nodes where spellcheck should be disabled.
|
|
41
|
+
*
|
|
42
|
+
* Confirmed CM6 markdown node names:
|
|
43
|
+
* - FencedCode
|
|
44
|
+
* - InlineCode
|
|
45
|
+
* - URL
|
|
46
|
+
*/
|
|
47
|
+
const SUPPRESSED_NODE_NAMES = new Set([
|
|
48
|
+
'FencedCode',
|
|
49
|
+
'InlineCode',
|
|
50
|
+
'URL',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Regexes for non-prose tokens that often appear in markdown paragraphs but
|
|
55
|
+
* should not be spellchecked.
|
|
56
|
+
*
|
|
57
|
+
* These are intentionally conservative:
|
|
58
|
+
* - quoted literals: 'foo/bar', "snake_case"
|
|
59
|
+
* - unix/relative paths: ./src/app, /usr/bin, ~/work/project
|
|
60
|
+
* - slash-delimited paths/modules: src/widgets/theme.js
|
|
61
|
+
*/
|
|
62
|
+
const NON_PROSE_PATTERNS = [
|
|
63
|
+
/'[^'\n]+'/g,
|
|
64
|
+
/"[^"\n]+"/g,
|
|
65
|
+
/(?:\.{1,2}\/|~\/|\/)[^\s'"`<>]+/g,
|
|
66
|
+
/(?:[A-Za-z0-9._-]+\/){1,}[A-Za-z0-9._-]+\/?/g,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function addRegexRanges(docText, baseFrom, ranges) {
|
|
70
|
+
for (const pattern of NON_PROSE_PATTERNS) {
|
|
71
|
+
pattern.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = pattern.exec(docText))) {
|
|
74
|
+
const from = baseFrom + match.index;
|
|
75
|
+
const to = from + match[0].length;
|
|
76
|
+
if (to > from) ranges.push({ from, to });
|
|
77
|
+
|
|
78
|
+
// Safety against zero-length regex matches
|
|
79
|
+
if (match[0].length === 0) {
|
|
80
|
+
pattern.lastIndex += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function mergeRanges(ranges) {
|
|
87
|
+
if (ranges.length <= 1) return ranges;
|
|
88
|
+
|
|
89
|
+
const sorted = ranges
|
|
90
|
+
.filter((r) => r && r.to > r.from)
|
|
91
|
+
.sort((a, b) => (a.from - b.from) || (a.to - b.to));
|
|
92
|
+
|
|
93
|
+
if (sorted.length === 0) return [];
|
|
94
|
+
|
|
95
|
+
const merged = [sorted[0]];
|
|
96
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
97
|
+
const current = sorted[i];
|
|
98
|
+
const last = merged[merged.length - 1];
|
|
99
|
+
|
|
100
|
+
if (current.from <= last.to) {
|
|
101
|
+
last.to = Math.max(last.to, current.to);
|
|
102
|
+
} else {
|
|
103
|
+
merged.push({ ...current });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return merged;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* ViewPlugin that disables spellcheck in non-prose regions.
|
|
112
|
+
*
|
|
113
|
+
* Performance:
|
|
114
|
+
* We only inspect visible ranges instead of the whole document. That keeps
|
|
115
|
+
* the work small even on long notes and reduces the chance that spellcheck
|
|
116
|
+
* feels laggy.
|
|
117
|
+
*/
|
|
118
|
+
const noSpellcheckInNonProse = ViewPlugin.fromClass(
|
|
119
|
+
class {
|
|
120
|
+
constructor(view) {
|
|
121
|
+
this.decorations = this.build(view);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
update(update) {
|
|
125
|
+
if (update.docChanged || update.viewportChanged) {
|
|
126
|
+
this.decorations = this.build(update.view);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
build(view) {
|
|
131
|
+
const collected = [];
|
|
132
|
+
const tree = syntaxTree(view.state);
|
|
133
|
+
|
|
134
|
+
for (const { from, to } of view.visibleRanges) {
|
|
135
|
+
tree.iterate({
|
|
136
|
+
from,
|
|
137
|
+
to,
|
|
138
|
+
enter(node) {
|
|
139
|
+
if (SUPPRESSED_NODE_NAMES.has(node.name) && node.from < node.to) {
|
|
140
|
+
collected.push({ from: node.from, to: node.to });
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const text = view.state.doc.sliceString(from, to);
|
|
146
|
+
addRegexRanges(text, from, collected);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const merged = mergeRanges(collected);
|
|
150
|
+
return Decoration.set(
|
|
151
|
+
merged.map((r) => noSpellcheckMark.range(r.from, r.to)),
|
|
152
|
+
true,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{ decorations: (v) => v.decorations },
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Create spellcheck extensions.
|
|
161
|
+
*
|
|
162
|
+
* @returns {import('@codemirror/state').Extension[]}
|
|
163
|
+
*/
|
|
164
|
+
export function createSpellcheckExtensions() {
|
|
165
|
+
return [proseSpellcheck, noSpellcheckInNonProse];
|
|
166
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# mrmd-editor/src/tables
|
|
2
|
+
|
|
3
|
+
Internal linked-table UI/editor adapter subsystem.
|
|
4
|
+
|
|
5
|
+
This folder is where MRMD-specific editor integration lives.
|
|
6
|
+
It should depend on pure table packages, but the pure packages must not depend back on this folder.
|
|
7
|
+
|
|
8
|
+
## Ownership
|
|
9
|
+
|
|
10
|
+
- linked-table block detection inside editor documents
|
|
11
|
+
- small/document view widget
|
|
12
|
+
- active-inline interactions
|
|
13
|
+
- full grid workspace shell
|
|
14
|
+
- editor commands for linked tables
|
|
15
|
+
- browser-side table job client
|
|
16
|
+
|
|
17
|
+
## Planned tree
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
mrmd-editor/src/tables/
|
|
21
|
+
index.js
|
|
22
|
+
facets.js
|
|
23
|
+
commands/
|
|
24
|
+
insert-linked-table.js
|
|
25
|
+
open-table-workspace.js
|
|
26
|
+
open-table-source.js
|
|
27
|
+
refresh-linked-table.js
|
|
28
|
+
parsing/
|
|
29
|
+
linked-table-blocks.js
|
|
30
|
+
anchors.js
|
|
31
|
+
state/
|
|
32
|
+
linked-table-state.js
|
|
33
|
+
table-workspace-state.js
|
|
34
|
+
decorations/
|
|
35
|
+
linked-table-decorations.js
|
|
36
|
+
widgets/
|
|
37
|
+
linked-table-widget.js
|
|
38
|
+
linked-table-chrome.js
|
|
39
|
+
linked-table-status.js
|
|
40
|
+
workspace/
|
|
41
|
+
controller.js
|
|
42
|
+
layout.js
|
|
43
|
+
result-grid.js
|
|
44
|
+
source-grid.js
|
|
45
|
+
panels/
|
|
46
|
+
join-panel.js
|
|
47
|
+
bind-rows-panel.js
|
|
48
|
+
bind-cols-panel.js
|
|
49
|
+
formula-panel.js
|
|
50
|
+
document-view-panel.js
|
|
51
|
+
source-info-panel.js
|
|
52
|
+
jobs/
|
|
53
|
+
client.js
|
|
54
|
+
status.js
|
|
55
|
+
styles.js
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Current phase
|
|
59
|
+
|
|
60
|
+
The first editor-side shell is now wired through the first real sort/import/source/workspace paths for:
|
|
61
|
+
- linked-table block detection from markdown/spec headers
|
|
62
|
+
- small embedded linked-table widget rendering
|
|
63
|
+
- linked badge/chrome row
|
|
64
|
+
- header-sort event dispatch
|
|
65
|
+
- explicit `Open grid` / `Open source` / `Reveal source` / `Open markdown` / `Refresh` action dispatch
|
|
66
|
+
- browser-side `tableJobs` client + wait/status helpers
|
|
67
|
+
- Yjs block-anchor creation for linked-table jobs
|
|
68
|
+
- controller wiring from header-sort widget actions to real `tableJobs` requests
|
|
69
|
+
- host-backed linked-table import insertion helpers in the editor layer
|
|
70
|
+
- first studio/status-bar UI command path for linked-table import
|
|
71
|
+
- block-local markdown-source reveal with a visible `Return to linked view` path
|
|
72
|
+
- first widget job-status badge updates for linked tables
|
|
73
|
+
- basic source-mtime stale detection for visible linked tables
|
|
74
|
+
- first real `Open grid` workspace panel hook (implemented in the Electron app for now)
|
|
75
|
+
- no reveal-on-click for linked tables in the markdown block-decoration path
|
|
76
|
+
|
|
77
|
+
## First slice here
|
|
78
|
+
|
|
79
|
+
Phase 1 editor work should only prove:
|
|
80
|
+
- linked-table block detection
|
|
81
|
+
- small embedded widget rendering
|
|
82
|
+
- linked badge/chrome row
|
|
83
|
+
- header-sort action
|
|
84
|
+
- `Open grid` shell
|
|
85
|
+
- no reveal-on-click for linked tables
|
|
86
|
+
|
|
87
|
+
Known gaps after the first manual app test:
|
|
88
|
+
- latency is visible because the first materialization path is still simple and mostly unoptimized
|
|
89
|
+
- widget chrome status is still only a first-pass badge layer, not the full stale/progress/error design yet
|
|
90
|
+
- the new workspace is still an MVP inspection shell, not yet the full editing/data-workbench experience
|
|
91
|
+
|
|
92
|
+
## Non-goals for the first slice
|
|
93
|
+
|
|
94
|
+
- full grid editing richness
|
|
95
|
+
- source editor tabs
|
|
96
|
+
- join/bind UI end to end
|
|
97
|
+
- raw-source editing UI
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-table import/insert helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function getHostApi(hostApi) {
|
|
6
|
+
if (hostApi) return hostApi;
|
|
7
|
+
if (typeof window !== 'undefined') return window.electronAPI || null;
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function ensureTrailingNewlinePair(text, side) {
|
|
12
|
+
const value = String(text || '');
|
|
13
|
+
if (!value) return '';
|
|
14
|
+
|
|
15
|
+
if (side === 'before') {
|
|
16
|
+
if (value.endsWith('\n\n')) return '';
|
|
17
|
+
if (value.endsWith('\n')) return '\n';
|
|
18
|
+
return '\n\n';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (value.startsWith('\n\n')) return '';
|
|
22
|
+
if (value.startsWith('\n')) return '\n';
|
|
23
|
+
return '\n\n';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function canImportLinkedTableFromHost(hostApi) {
|
|
27
|
+
const host = getHostApi(hostApi);
|
|
28
|
+
return typeof host?.table?.importDelimited === 'function';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function normalizeLinkedTableBlockInsertion(docText, from, to, blockMarkdown) {
|
|
32
|
+
const fullText = String(docText || '');
|
|
33
|
+
const safeFrom = Math.max(0, Math.min(fullText.length, Number.isInteger(from) ? from : 0));
|
|
34
|
+
const safeTo = Math.max(safeFrom, Math.min(fullText.length, Number.isInteger(to) ? to : safeFrom));
|
|
35
|
+
const before = fullText.slice(0, safeFrom);
|
|
36
|
+
const after = fullText.slice(safeTo);
|
|
37
|
+
const block = String(blockMarkdown || '').trim();
|
|
38
|
+
|
|
39
|
+
if (!block) {
|
|
40
|
+
return {
|
|
41
|
+
from: safeFrom,
|
|
42
|
+
to: safeTo,
|
|
43
|
+
insert: '',
|
|
44
|
+
selectionAnchor: safeFrom,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const prefix = before.length > 0 ? ensureTrailingNewlinePair(before, 'before') : '';
|
|
49
|
+
const suffix = after.length > 0 ? ensureTrailingNewlinePair(after, 'after') : '';
|
|
50
|
+
const insert = `${prefix}${block}${suffix}`;
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
from: safeFrom,
|
|
54
|
+
to: safeTo,
|
|
55
|
+
insert,
|
|
56
|
+
selectionAnchor: safeFrom + insert.length,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function insertLinkedTableBlock(editor, blockMarkdown, options = {}) {
|
|
61
|
+
const view = editor?.view || options.view;
|
|
62
|
+
if (!view?.state || typeof view.dispatch !== 'function') {
|
|
63
|
+
throw new Error('insertLinkedTableBlock requires an editor/view with dispatch support');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const selection = options.selection || view.state.selection?.main || { from: 0, to: 0 };
|
|
67
|
+
const docText = view.state.doc?.toString?.() || '';
|
|
68
|
+
const normalized = normalizeLinkedTableBlockInsertion(docText, selection.from, selection.to, blockMarkdown);
|
|
69
|
+
|
|
70
|
+
view.dispatch({
|
|
71
|
+
changes: {
|
|
72
|
+
from: normalized.from,
|
|
73
|
+
to: normalized.to,
|
|
74
|
+
insert: normalized.insert,
|
|
75
|
+
},
|
|
76
|
+
selection: {
|
|
77
|
+
anchor: normalized.selectionAnchor,
|
|
78
|
+
},
|
|
79
|
+
scrollIntoView: true,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function importLinkedTableFromHost(editor, options = {}) {
|
|
86
|
+
const host = getHostApi(options.hostApi);
|
|
87
|
+
if (!canImportLinkedTableFromHost(host)) {
|
|
88
|
+
throw new Error('Linked-table import requires an Electron host exposing `electronAPI.table.importDelimited()`');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const projectRoot = options.projectRoot || editor?.getLinkedTableHostContext?.()?.projectRoot || null;
|
|
92
|
+
const documentPath = options.documentPath || editor?.getLinkedTableHostContext?.()?.documentPath || null;
|
|
93
|
+
const sourceFilePath = options.sourceFilePath;
|
|
94
|
+
|
|
95
|
+
if (!projectRoot) throw new Error('Linked-table import requires a project root');
|
|
96
|
+
if (!documentPath) throw new Error('Linked-table import requires a document path');
|
|
97
|
+
if (!sourceFilePath) throw new Error('Linked-table import requires a source file path');
|
|
98
|
+
|
|
99
|
+
const result = await host.table.importDelimited({
|
|
100
|
+
projectRoot,
|
|
101
|
+
documentPath,
|
|
102
|
+
sourceFilePath,
|
|
103
|
+
tableId: options.tableId,
|
|
104
|
+
label: options.label,
|
|
105
|
+
cacheFormat: options.cacheFormat,
|
|
106
|
+
maxRows: options.maxRows,
|
|
107
|
+
overflow: options.overflow,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
insertLinkedTableBlock(editor, result.blockMarkdown, {
|
|
111
|
+
selection: options.selection,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export default {
|
|
118
|
+
canImportLinkedTableFromHost,
|
|
119
|
+
normalizeLinkedTableBlockInsertion,
|
|
120
|
+
insertLinkedTableBlock,
|
|
121
|
+
importLinkedTableFromHost,
|
|
122
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-table workspace command/event helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const LINKED_TABLE_EVENT = 'mrmd-linked-table-action';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Dispatch a linked-table UI action from the editor surface.
|
|
9
|
+
* Host/app code can listen on `view.dom` or `window`.
|
|
10
|
+
*
|
|
11
|
+
* @param {import('@codemirror/view').EditorView} view
|
|
12
|
+
* @param {Object} detail
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function dispatchLinkedTableAction(view, detail) {
|
|
16
|
+
if (!view?.dom) return false;
|
|
17
|
+
const event = new CustomEvent(LINKED_TABLE_EVENT, {
|
|
18
|
+
bubbles: true,
|
|
19
|
+
detail,
|
|
20
|
+
});
|
|
21
|
+
view.dom.dispatchEvent(event);
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Convenience helper for opening the full linked-table workspace.
|
|
27
|
+
*
|
|
28
|
+
* @param {import('@codemirror/view').EditorView} view
|
|
29
|
+
* @param {Object} detail
|
|
30
|
+
* @returns {boolean}
|
|
31
|
+
*/
|
|
32
|
+
export function openLinkedTableWorkspace(view, detail) {
|
|
33
|
+
return dispatchLinkedTableAction(view, {
|
|
34
|
+
action: 'open-grid',
|
|
35
|
+
...detail,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default {
|
|
40
|
+
LINKED_TABLE_EVENT,
|
|
41
|
+
dispatchLinkedTableAction,
|
|
42
|
+
openLinkedTableWorkspace,
|
|
43
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-table editor integration exports.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { findLinkedTableBlocksInState, getLinkedTableBlockRange, isRangeInsideLinkedTable } from './parsing/linked-table-blocks.js';
|
|
6
|
+
export { createLinkedTableBlockAnchor, resolveLinkedTableBlockAnchor } from './parsing/anchors.js';
|
|
7
|
+
export {
|
|
8
|
+
linkedTableMarkdownState,
|
|
9
|
+
revealLinkedTableMarkdownEffect,
|
|
10
|
+
hideLinkedTableMarkdownEffect,
|
|
11
|
+
clearLinkedTableMarkdownEffect,
|
|
12
|
+
isLinkedTableMarkdownOpen,
|
|
13
|
+
} from './state/linked-table-state.js';
|
|
14
|
+
export { LINKED_TABLE_EVENT, dispatchLinkedTableAction, openLinkedTableWorkspace } from './commands/open-table-workspace.js';
|
|
15
|
+
export {
|
|
16
|
+
canImportLinkedTableFromHost,
|
|
17
|
+
normalizeLinkedTableBlockInsertion,
|
|
18
|
+
insertLinkedTableBlock,
|
|
19
|
+
importLinkedTableFromHost,
|
|
20
|
+
} from './commands/insert-linked-table.js';
|
|
21
|
+
export { TableJobsClient, TABLE_JOB_STATUS, createTableJobsClient } from './jobs/client.js';
|
|
22
|
+
export { LinkedTableController, createLinkedTableController } from './workspace/controller.js';
|
|
23
|
+
export { LinkedTableWidget } from './widgets/linked-table-widget.js';
|
|
24
|
+
export { LinkedTableSourceBannerWidget } from './widgets/linked-table-source-banner.js';
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side linked-table job coordination.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the monitor execution coordination pattern but for `tableJobs`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Y from 'yjs';
|
|
8
|
+
|
|
9
|
+
export const TABLE_JOB_STATUS = {
|
|
10
|
+
REQUESTED: 'requested',
|
|
11
|
+
CLAIMED: 'claimed',
|
|
12
|
+
RUNNING: 'running',
|
|
13
|
+
WRITING: 'writing',
|
|
14
|
+
COMPLETED: 'completed',
|
|
15
|
+
ERROR: 'error',
|
|
16
|
+
CANCELLED: 'cancelled',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class TableJobsClient {
|
|
20
|
+
constructor(ydoc, clientId = ydoc?.clientID) {
|
|
21
|
+
this.ydoc = ydoc;
|
|
22
|
+
this.clientId = clientId;
|
|
23
|
+
this.jobs = ydoc.getMap('tableJobs');
|
|
24
|
+
this._statusCallbacks = new Map();
|
|
25
|
+
this._observers = new Set();
|
|
26
|
+
this._setupObserver();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static generateJobId() {
|
|
30
|
+
return `tablejob-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_setupObserver() {
|
|
34
|
+
const observer = (event) => {
|
|
35
|
+
event.changes.keys.forEach((change, jobId) => {
|
|
36
|
+
const job = this.jobs.get(jobId);
|
|
37
|
+
const callback = this._statusCallbacks.get(jobId);
|
|
38
|
+
if (callback && job) {
|
|
39
|
+
callback(job.status, job);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
this.jobs.observe(observer);
|
|
45
|
+
this._observers.add(observer);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
requestJob({ tableId, blockAnchor = null, spec = null, jobType = 'applyOps', opList = [], metadata = {} }) {
|
|
49
|
+
const jobId = TableJobsClient.generateJobId();
|
|
50
|
+
this.jobs.set(jobId, {
|
|
51
|
+
id: jobId,
|
|
52
|
+
tableId,
|
|
53
|
+
jobType,
|
|
54
|
+
status: TABLE_JOB_STATUS.REQUESTED,
|
|
55
|
+
requestedBy: this.clientId,
|
|
56
|
+
requestedAt: Date.now(),
|
|
57
|
+
claimedBy: null,
|
|
58
|
+
claimedAt: null,
|
|
59
|
+
completedAt: null,
|
|
60
|
+
blockAnchor,
|
|
61
|
+
spec,
|
|
62
|
+
opList,
|
|
63
|
+
metadata,
|
|
64
|
+
result: null,
|
|
65
|
+
error: null,
|
|
66
|
+
});
|
|
67
|
+
return jobId;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
requestSort({ tableId, blockAnchor = null, spec = null, column, direction = 'asc', metadata = {} }) {
|
|
71
|
+
return this.requestJob({
|
|
72
|
+
tableId,
|
|
73
|
+
blockAnchor,
|
|
74
|
+
spec,
|
|
75
|
+
jobType: 'applyOps',
|
|
76
|
+
opList: [{ type: 'sort', column, direction }],
|
|
77
|
+
metadata,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
requestRefresh({ tableId, blockAnchor = null, spec = null, metadata = {} }) {
|
|
82
|
+
return this.requestJob({
|
|
83
|
+
tableId,
|
|
84
|
+
blockAnchor,
|
|
85
|
+
spec,
|
|
86
|
+
jobType: 'refresh',
|
|
87
|
+
opList: [],
|
|
88
|
+
metadata,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
getJob(jobId) {
|
|
93
|
+
return this.jobs.get(jobId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
cancelJob(jobId) {
|
|
97
|
+
const job = this.jobs.get(jobId);
|
|
98
|
+
if (!job) return;
|
|
99
|
+
if ([TABLE_JOB_STATUS.COMPLETED, TABLE_JOB_STATUS.ERROR, TABLE_JOB_STATUS.CANCELLED].includes(job.status)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
this.jobs.set(jobId, {
|
|
103
|
+
...job,
|
|
104
|
+
status: TABLE_JOB_STATUS.CANCELLED,
|
|
105
|
+
completedAt: Date.now(),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onStatusChange(jobId, callback) {
|
|
110
|
+
this._statusCallbacks.set(jobId, callback);
|
|
111
|
+
return () => {
|
|
112
|
+
this._statusCallbacks.delete(jobId);
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
waitForStatus(jobId, targetStatus, timeout = 30000) {
|
|
117
|
+
const statuses = Array.isArray(targetStatus) ? targetStatus : [targetStatus];
|
|
118
|
+
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const current = this.getJob(jobId);
|
|
121
|
+
if (current && statuses.includes(current.status)) {
|
|
122
|
+
resolve(current);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let timeoutId = null;
|
|
127
|
+
const unsubscribe = this.onStatusChange(jobId, (status, job) => {
|
|
128
|
+
if (!statuses.includes(status)) return;
|
|
129
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
130
|
+
unsubscribe();
|
|
131
|
+
resolve(job);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
timeoutId = setTimeout(() => {
|
|
135
|
+
unsubscribe();
|
|
136
|
+
reject(new Error(`Timeout waiting for table job status ${statuses.join('/')} on ${jobId}`));
|
|
137
|
+
}, timeout);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
destroy() {
|
|
142
|
+
for (const observer of this._observers) {
|
|
143
|
+
this.jobs.unobserve(observer);
|
|
144
|
+
}
|
|
145
|
+
this._observers.clear();
|
|
146
|
+
this._statusCallbacks.clear();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function createTableJobsClient(ydoc) {
|
|
151
|
+
return new TableJobsClient(ydoc, ydoc.clientID);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export default {
|
|
155
|
+
TABLE_JOB_STATUS,
|
|
156
|
+
TableJobsClient,
|
|
157
|
+
createTableJobsClient,
|
|
158
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Linked-table Yjs anchor helpers.
|
|
3
|
+
*
|
|
4
|
+
* These anchors let table jobs survive surrounding document edits.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as Y from 'yjs';
|
|
8
|
+
|
|
9
|
+
function assertRange(range) {
|
|
10
|
+
const from = range?.headerFrom ?? range?.from;
|
|
11
|
+
const to = range?.snapshotTo ?? range?.to;
|
|
12
|
+
|
|
13
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
14
|
+
throw new TypeError('Linked-table anchor range must include a non-negative `from`/`headerFrom`');
|
|
15
|
+
}
|
|
16
|
+
if (!Number.isInteger(to) || to < from) {
|
|
17
|
+
throw new TypeError('Linked-table anchor range must include a `to`/`snapshotTo` >= `from`');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return { from, to };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toRelativePositionJson(yText, index, assoc) {
|
|
24
|
+
const relPos = Y.createRelativePositionFromTypeIndex(yText, index, assoc);
|
|
25
|
+
return Y.relativePositionToJSON(relPos);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function toAbsoluteIndex(ydoc, relPosJson) {
|
|
29
|
+
if (!relPosJson) return null;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const relPos = Y.createRelativePositionFromJSON(relPosJson);
|
|
33
|
+
const absPos = Y.createAbsolutePositionFromRelativePosition(relPos, ydoc);
|
|
34
|
+
return absPos?.index ?? null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Create a Yjs-stable anchor for one linked-table block.
|
|
42
|
+
*
|
|
43
|
+
* Start uses right association so inserts at the exact start stay outside the block.
|
|
44
|
+
* End uses left association so inserts at the exact end stay outside the block.
|
|
45
|
+
*/
|
|
46
|
+
export function createLinkedTableBlockAnchor(yText, range, options = {}) {
|
|
47
|
+
if (!yText?.doc) {
|
|
48
|
+
throw new TypeError('createLinkedTableBlockAnchor requires a Y.Text attached to a Y.Doc');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const resolved = assertRange(range);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
type: 'linked-table-block-anchor-v1',
|
|
55
|
+
tableId: options.tableId || range?.tableId || range?.spec?.id || null,
|
|
56
|
+
from: toRelativePositionJson(yText, resolved.from, 1),
|
|
57
|
+
to: toRelativePositionJson(yText, resolved.to, -1),
|
|
58
|
+
createdAt: Date.now(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a linked-table anchor back to absolute document offsets.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveLinkedTableBlockAnchor(ydoc, anchor) {
|
|
66
|
+
if (!ydoc || !anchor) return null;
|
|
67
|
+
|
|
68
|
+
const from = toAbsoluteIndex(ydoc, anchor.from);
|
|
69
|
+
const to = toAbsoluteIndex(ydoc, anchor.to);
|
|
70
|
+
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) return null;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
from,
|
|
74
|
+
to,
|
|
75
|
+
tableId: anchor.tableId || null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export default {
|
|
80
|
+
createLinkedTableBlockAnchor,
|
|
81
|
+
resolveLinkedTableBlockAnchor,
|
|
82
|
+
};
|