@neo4j-cypher/react-codemirror 2.0.0-next.36 → 2.0.0-next.38
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/CHANGELOG.md +19 -0
- package/dist/src/CypherEditor.d.ts +18 -2
- package/dist/src/CypherEditor.js +41 -1
- package/dist/src/CypherEditor.js.map +1 -1
- package/dist/src/diffView.d.ts +13 -0
- package/dist/src/diffView.js +11 -0
- package/dist/src/diffView.js.map +1 -0
- package/dist/src/e2e_tests/syntaxValidation.spec.js +0 -3
- package/dist/src/e2e_tests/syntaxValidation.spec.js.map +1 -1
- package/dist/src/editorActions.d.ts +17 -0
- package/dist/src/editorActions.js +191 -0
- package/dist/src/editorActions.js.map +1 -0
- package/dist/src/hostCallbacks.d.ts +9 -0
- package/dist/src/hostCallbacks.js +2 -0
- package/dist/src/hostCallbacks.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/inlinePanel.d.ts +3 -9
- package/dist/src/inlinePanel.js +30 -17
- package/dist/src/inlinePanel.js.map +1 -1
- package/dist/src/lang-cypher/createCypherTheme.d.ts +12 -1
- package/dist/src/lang-cypher/createCypherTheme.js +20 -1
- package/dist/src/lang-cypher/createCypherTheme.js.map +1 -1
- package/dist/src/lang-cypher/lintWorker.mjs +222 -221
- package/dist/src/themes.js +14 -0
- package/dist/src/themes.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -5
- package/src/CypherEditor.tsx +75 -3
- package/src/diffView.ts +24 -0
- package/src/e2e_tests/syntaxValidation.spec.tsx +0 -15
- package/src/editorActions.ts +250 -0
- package/src/hostCallbacks.ts +9 -0
- package/src/index.ts +1 -0
- package/src/inlinePanel.ts +45 -25
- package/src/lang-cypher/createCypherTheme.ts +33 -0
- package/src/lang-cypher/lintWorker.mjs +222 -221
- package/src/themes.ts +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neo4j-cypher/react-codemirror",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.38",
|
|
4
4
|
"keywords": [
|
|
5
5
|
"codemirror",
|
|
6
6
|
"codemirror 6",
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"@codemirror/commands": "^6.8.1",
|
|
35
35
|
"@codemirror/language": "^6.11.2",
|
|
36
36
|
"@codemirror/lint": "^6.8.5",
|
|
37
|
+
"@codemirror/merge": "^6.12.1",
|
|
37
38
|
"@codemirror/search": "^6.5.11",
|
|
38
39
|
"@codemirror/state": "^6.5.2",
|
|
39
40
|
"@codemirror/view": "^6.38.1",
|
|
@@ -47,8 +48,8 @@
|
|
|
47
48
|
"style-mod": "^4.1.2",
|
|
48
49
|
"vscode-languageserver-types": "^3.17.3",
|
|
49
50
|
"workerpool": "^9.3.3",
|
|
50
|
-
"@neo4j-cypher/language-support": "2.0.0-next.
|
|
51
|
-
"@neo4j-cypher/lint-worker": "1.10.1-next.
|
|
51
|
+
"@neo4j-cypher/language-support": "2.0.0-next.35",
|
|
52
|
+
"@neo4j-cypher/lint-worker": "1.10.1-next.12"
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@neo4j-ndl/base": "^3.2.10",
|
|
@@ -67,12 +68,12 @@
|
|
|
67
68
|
"vite": "^4.5.10"
|
|
68
69
|
},
|
|
69
70
|
"peerDependencies": {
|
|
70
|
-
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
|
71
|
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
|
|
72
|
+
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
|
71
73
|
},
|
|
72
74
|
"engines": {
|
|
73
75
|
"node": ">=24.11.1"
|
|
74
76
|
},
|
|
75
|
-
"engineStrict": true,
|
|
76
77
|
"scripts": {
|
|
77
78
|
"dev": "tsc --watch",
|
|
78
79
|
"build": "pnpm copy-lint-worker && tsc --declaration --outDir dist/",
|
package/src/CypherEditor.tsx
CHANGED
|
@@ -18,13 +18,20 @@ import {
|
|
|
18
18
|
type InlinePanelCallbacks,
|
|
19
19
|
type InlinePanelController,
|
|
20
20
|
} from './inlinePanel';
|
|
21
|
+
import { createDiffExtension, type DiffProps } from './diffView';
|
|
22
|
+
import {
|
|
23
|
+
createEditorActionsController,
|
|
24
|
+
type EditorActionsCallbacks,
|
|
25
|
+
type EditorActionsController,
|
|
26
|
+
} from './editorActions';
|
|
21
27
|
import {
|
|
22
28
|
formatQuery,
|
|
23
29
|
CypherLanguageService,
|
|
24
30
|
type DbSchema,
|
|
25
31
|
} from '@neo4j-cypher/language-support';
|
|
26
32
|
import debounce from 'lodash.debounce';
|
|
27
|
-
import { Component, createRef } from 'react';
|
|
33
|
+
import { Component, createRef, type ReactNode } from 'react';
|
|
34
|
+
import { createPortal } from 'react-dom';
|
|
28
35
|
import { DEBOUNCE_TIME } from './constants';
|
|
29
36
|
import {
|
|
30
37
|
replaceHistory,
|
|
@@ -190,6 +197,17 @@ export interface CypherEditorProps {
|
|
|
190
197
|
* The widget DOM is only rebuilt when `pos` or `placement` change
|
|
191
198
|
*/
|
|
192
199
|
inlinePanel?: InlinePanelProps | null;
|
|
200
|
+
/**
|
|
201
|
+
* Render a unified diff of the current document against `diff.original`.
|
|
202
|
+
* Deleted lines are shown as uneditable widgets.
|
|
203
|
+
*/
|
|
204
|
+
diff?: DiffProps | null;
|
|
205
|
+
/**
|
|
206
|
+
* React content rendered as a cluster of action buttons (e.g. a context menu
|
|
207
|
+
* + Run) pinned to the top-right corner of the editor. Omit (or `null`) to
|
|
208
|
+
* hide it.
|
|
209
|
+
*/
|
|
210
|
+
editorActions?: ReactNode;
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
export type InlinePanelProps = {
|
|
@@ -291,6 +309,7 @@ const lineNumbersCompartment = new Compartment();
|
|
|
291
309
|
const readOnlyCompartment = new Compartment();
|
|
292
310
|
const placeholderCompartment = new Compartment();
|
|
293
311
|
const domEventHandlerCompartment = new Compartment();
|
|
312
|
+
const diffCompartment = new Compartment();
|
|
294
313
|
|
|
295
314
|
const formatLineNumber =
|
|
296
315
|
(prompt?: string) => (a: number, state: EditorState) => {
|
|
@@ -301,7 +320,9 @@ const formatLineNumber =
|
|
|
301
320
|
return a.toString();
|
|
302
321
|
};
|
|
303
322
|
|
|
304
|
-
type CypherEditorState = {
|
|
323
|
+
type CypherEditorState = {
|
|
324
|
+
editorActionsContainer: HTMLElement | null;
|
|
325
|
+
};
|
|
305
326
|
|
|
306
327
|
const ExternalEdit = Annotation.define<boolean>();
|
|
307
328
|
const WorkerURL = new URL('./lang-cypher/lintWorker.mjs', import.meta.url)
|
|
@@ -386,6 +407,17 @@ export class CypherEditor extends Component<
|
|
|
386
407
|
editorView: React.MutableRefObject<EditorView> = createRef();
|
|
387
408
|
private schemaRef: React.MutableRefObject<CypherConfig> = createRef();
|
|
388
409
|
private inlinePanelController: InlinePanelController | null = null;
|
|
410
|
+
private editorActionsController: EditorActionsController | null = null;
|
|
411
|
+
|
|
412
|
+
state: CypherEditorState = {
|
|
413
|
+
editorActionsContainer: null,
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
private editorActionsCallbacks: EditorActionsCallbacks = {
|
|
417
|
+
onMount: (container) =>
|
|
418
|
+
this.setState({ editorActionsContainer: container }),
|
|
419
|
+
onUnmount: () => this.setState({ editorActionsContainer: null }),
|
|
420
|
+
};
|
|
389
421
|
|
|
390
422
|
/**
|
|
391
423
|
* Format Cypher query
|
|
@@ -498,6 +530,7 @@ export class CypherEditor extends Component<
|
|
|
498
530
|
);
|
|
499
531
|
|
|
500
532
|
this.inlinePanelController = createInlinePanelController();
|
|
533
|
+
this.editorActionsController = createEditorActionsController();
|
|
501
534
|
|
|
502
535
|
const changeListener = this.debouncedOnChange
|
|
503
536
|
? [
|
|
@@ -565,6 +598,10 @@ export class CypherEditor extends Component<
|
|
|
565
598
|
})
|
|
566
599
|
: [],
|
|
567
600
|
this.inlinePanelController.extension,
|
|
601
|
+
diffCompartment.of(
|
|
602
|
+
this.props.diff ? createDiffExtension(this.props.diff) : [],
|
|
603
|
+
),
|
|
604
|
+
this.editorActionsController.extension,
|
|
568
605
|
],
|
|
569
606
|
doc: this.props.value,
|
|
570
607
|
});
|
|
@@ -586,6 +623,22 @@ export class CypherEditor extends Component<
|
|
|
586
623
|
if (this.props.inlinePanel) {
|
|
587
624
|
this.openInlinePanel(this.props.inlinePanel);
|
|
588
625
|
}
|
|
626
|
+
|
|
627
|
+
if (this.props.editorActions != null) {
|
|
628
|
+
this.setEditorActions(true);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private setEditorActions(active: boolean): void {
|
|
633
|
+
const view = this.editorView.current;
|
|
634
|
+
const controller = this.editorActionsController;
|
|
635
|
+
if (!view || !controller) {
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
view.dispatch({
|
|
640
|
+
effects: controller.set(active ? this.editorActionsCallbacks : null),
|
|
641
|
+
});
|
|
589
642
|
}
|
|
590
643
|
|
|
591
644
|
private openInlinePanel(
|
|
@@ -734,6 +787,20 @@ export class CypherEditor extends Component<
|
|
|
734
787
|
}
|
|
735
788
|
}
|
|
736
789
|
|
|
790
|
+
if (prevProps.diff?.original !== this.props.diff?.original) {
|
|
791
|
+
this.editorView.current.dispatch({
|
|
792
|
+
effects: diffCompartment.reconfigure(
|
|
793
|
+
this.props.diff ? createDiffExtension(this.props.diff) : [],
|
|
794
|
+
),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const hadActions = prevProps.editorActions != null;
|
|
799
|
+
const hasActions = this.props.editorActions != null;
|
|
800
|
+
if (hadActions !== hasActions) {
|
|
801
|
+
this.setEditorActions(hasActions);
|
|
802
|
+
}
|
|
803
|
+
|
|
737
804
|
if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
|
|
738
805
|
this.editorView.current.dispatch({
|
|
739
806
|
effects: domEventHandlerCompartment.reconfigure(
|
|
@@ -778,11 +845,16 @@ export class CypherEditor extends Component<
|
|
|
778
845
|
const themeClass =
|
|
779
846
|
typeof theme === 'string' ? `cm-theme-${theme}` : 'cm-theme';
|
|
780
847
|
|
|
848
|
+
const { editorActionsContainer } = this.state;
|
|
849
|
+
|
|
781
850
|
return (
|
|
782
851
|
<div
|
|
783
852
|
ref={this.editorContainer}
|
|
784
853
|
className={`${themeClass}${className ? ` ${className}` : ''}`}
|
|
785
|
-
|
|
854
|
+
>
|
|
855
|
+
{editorActionsContainer &&
|
|
856
|
+
createPortal(this.props.editorActions, editorActionsContainer)}
|
|
857
|
+
</div>
|
|
786
858
|
);
|
|
787
859
|
}
|
|
788
860
|
}
|
package/src/diffView.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { unifiedMergeView } from '@codemirror/merge';
|
|
2
|
+
import type { Extension } from '@codemirror/state';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Props for rendering an inline diff in the editor.
|
|
6
|
+
*
|
|
7
|
+
* The diff is computed between {@link DiffProps.original} and the *current*
|
|
8
|
+
* editor document, so streaming/external updates to the document re-diff
|
|
9
|
+
* automatically against the same original.
|
|
10
|
+
*/
|
|
11
|
+
export type DiffProps = {
|
|
12
|
+
/** The baseline document the current editor content is compared against. */
|
|
13
|
+
original: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function createDiffExtension({ original }: DiffProps): Extension {
|
|
17
|
+
return unifiedMergeView({
|
|
18
|
+
original,
|
|
19
|
+
highlightChanges: true,
|
|
20
|
+
syntaxHighlightDeletions: true,
|
|
21
|
+
mergeControls: false,
|
|
22
|
+
gutter: true,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -131,11 +131,6 @@ test('Semantic errors are correctly accumulated', async ({ page, mount }) => {
|
|
|
131
131
|
|
|
132
132
|
await mount(<CypherEditor value={query} />);
|
|
133
133
|
|
|
134
|
-
await editorPage.checkErrorMessage(
|
|
135
|
-
'MATCH (n)',
|
|
136
|
-
'Query cannot conclude with MATCH (must be a RETURN clause, a FINISH clause, an update clause, a unit subquery call, or a procedure call with no YIELD).',
|
|
137
|
-
);
|
|
138
|
-
|
|
139
134
|
await editorPage.checkErrorMessage(
|
|
140
135
|
'-1',
|
|
141
136
|
"Invalid input. '-1' is not a valid value. Must be a positive integer.",
|
|
@@ -151,11 +146,6 @@ test('Multiline errors are correctly placed', async ({ page, mount }) => {
|
|
|
151
146
|
|
|
152
147
|
await mount(<CypherEditor value={query} />);
|
|
153
148
|
|
|
154
|
-
await editorPage.checkErrorMessage(
|
|
155
|
-
'MATCH (n)',
|
|
156
|
-
'Query cannot conclude with MATCH (must be a RETURN clause, a FINISH clause, an update clause, a unit subquery call, or a procedure call with no YIELD).',
|
|
157
|
-
);
|
|
158
|
-
|
|
159
149
|
await editorPage.checkErrorMessage(
|
|
160
150
|
'-1',
|
|
161
151
|
"Invalid input. '-1' is not a valid value. Must be a positive integer.",
|
|
@@ -171,11 +161,6 @@ test('Validation errors are correctly overlapped', async ({ page, mount }) => {
|
|
|
171
161
|
|
|
172
162
|
await mount(<CypherEditor value={query} />);
|
|
173
163
|
|
|
174
|
-
await editorPage.checkErrorMessage(
|
|
175
|
-
'-1',
|
|
176
|
-
'Query cannot conclude with CALL (must be a RETURN clause, a FINISH clause, an update clause, a unit subquery call, or a procedure call with no YIELD).',
|
|
177
|
-
);
|
|
178
|
-
|
|
179
164
|
await editorPage.checkErrorMessage(
|
|
180
165
|
'-1',
|
|
181
166
|
"Invalid input. '-1' is not a valid value. Must be a positive integer.",
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { StateEffect, StateField, type Extension } from '@codemirror/state';
|
|
2
|
+
import {
|
|
3
|
+
Decoration,
|
|
4
|
+
EditorView,
|
|
5
|
+
ViewPlugin,
|
|
6
|
+
type ViewUpdate,
|
|
7
|
+
WidgetType,
|
|
8
|
+
} from '@codemirror/view';
|
|
9
|
+
import type { HostPortalCallbacks } from './hostCallbacks';
|
|
10
|
+
|
|
11
|
+
/** Lifecycle callbacks for the editor action buttons. See {@link HostPortalCallbacks}. */
|
|
12
|
+
export type EditorActionsCallbacks = HostPortalCallbacks;
|
|
13
|
+
|
|
14
|
+
export type EditorActionsController = {
|
|
15
|
+
/** CodeMirror extension to register on the editor. */
|
|
16
|
+
extension: Extension;
|
|
17
|
+
/**
|
|
18
|
+
* Drive the action buttons from the host's state: pass callbacks to mount
|
|
19
|
+
* them, or `null` to unmount. Returns the effect for the host to dispatch.
|
|
20
|
+
*
|
|
21
|
+
* Visibility is purely a function of whether callbacks exist — there's no
|
|
22
|
+
* separate show/hide toggle.
|
|
23
|
+
*/
|
|
24
|
+
set: (callbacks: EditorActionsCallbacks | null) => StateEffect<boolean>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Renders a host-provided cluster of action buttons at the top-right corner
|
|
29
|
+
* of the editor.
|
|
30
|
+
*
|
|
31
|
+
* The cluster is split into two cooperating pieces so it can both reserve
|
|
32
|
+
* space *and* stay pinned — which a single element can't do (reserving needs
|
|
33
|
+
* to be in flow, pinning needs to be out of it):
|
|
34
|
+
*
|
|
35
|
+
* - A **spacer**: an empty, floated inline widget at the very start of the
|
|
36
|
+
* document. Being in flow, it makes the first line's text wrap around it —
|
|
37
|
+
* and because it's a float, the browser handles arbitrary cluster heights.
|
|
38
|
+
* It carries no content; it only reserves the footprint.
|
|
39
|
+
* - An **overlay**: the real, host-rendered buttons in a container pinned
|
|
40
|
+
* `absolute` on the editor element (which does not scroll). It never moves
|
|
41
|
+
* when the document grows, when an inline panel opens above the first line,
|
|
42
|
+
* or when the content scrolls — content simply scrolls behind it.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
const SPACER_BOTTOM_GAP = 2;
|
|
46
|
+
const SPACER_LEFT_GAP = 4;
|
|
47
|
+
|
|
48
|
+
export function createEditorActionsController(): EditorActionsController {
|
|
49
|
+
const setActiveEffect = StateEffect.define<boolean>();
|
|
50
|
+
|
|
51
|
+
let callbacksRef: EditorActionsCallbacks | null = null;
|
|
52
|
+
|
|
53
|
+
const activeField = StateField.define<boolean>({
|
|
54
|
+
create: () => false,
|
|
55
|
+
update(active, transaction) {
|
|
56
|
+
for (const effect of transaction.effects) {
|
|
57
|
+
if (effect.is(setActiveEffect)) {
|
|
58
|
+
active = effect.value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return active;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
class SpacerWidget extends WidgetType {
|
|
66
|
+
toDOM(): HTMLElement {
|
|
67
|
+
const spacer = document.createElement('div');
|
|
68
|
+
spacer.className = 'cm-editor-actions-spacer';
|
|
69
|
+
spacer.setAttribute('aria-hidden', 'true');
|
|
70
|
+
return spacer;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
eq(): boolean {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
ignoreEvent(): boolean {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const spacer = EditorView.decorations.compute(
|
|
83
|
+
['doc', activeField],
|
|
84
|
+
(state) => {
|
|
85
|
+
if (!state.field(activeField) || state.doc.length === 0) {
|
|
86
|
+
return Decoration.none;
|
|
87
|
+
}
|
|
88
|
+
return Decoration.set([
|
|
89
|
+
Decoration.widget({
|
|
90
|
+
widget: new SpacerWidget(),
|
|
91
|
+
side: -1,
|
|
92
|
+
}).range(0),
|
|
93
|
+
]);
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const overlay = ViewPlugin.fromClass(
|
|
98
|
+
class {
|
|
99
|
+
private dom: HTMLElement | null = null;
|
|
100
|
+
private resizeObserver: ResizeObserver | null = null;
|
|
101
|
+
private mounted: EditorActionsCallbacks | null = null;
|
|
102
|
+
|
|
103
|
+
constructor(view: EditorView) {
|
|
104
|
+
if (view.state.field(activeField)) {
|
|
105
|
+
this.mount(view);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
update(update: ViewUpdate): void {
|
|
110
|
+
const isActive = update.state.field(activeField);
|
|
111
|
+
if (isActive && !this.dom) {
|
|
112
|
+
this.mount(update.view);
|
|
113
|
+
} else if (!isActive && this.dom) {
|
|
114
|
+
this.unmount();
|
|
115
|
+
} else if (
|
|
116
|
+
this.dom &&
|
|
117
|
+
(update.geometryChanged ||
|
|
118
|
+
update.docChanged ||
|
|
119
|
+
update.viewportChanged)
|
|
120
|
+
) {
|
|
121
|
+
this.align(update.view);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
destroy(): void {
|
|
126
|
+
this.unmount();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private mount(view: EditorView): void {
|
|
130
|
+
const container = document.createElement('div');
|
|
131
|
+
container.className = 'cm-editor-actions';
|
|
132
|
+
view.dom.appendChild(container);
|
|
133
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
134
|
+
view.dom.style.setProperty(
|
|
135
|
+
'--cm-editor-actions-width',
|
|
136
|
+
`${container.offsetWidth}px`,
|
|
137
|
+
);
|
|
138
|
+
view.dom.style.setProperty(
|
|
139
|
+
'--cm-editor-actions-height',
|
|
140
|
+
`${container.offsetHeight}px`,
|
|
141
|
+
);
|
|
142
|
+
view.dom.style.setProperty(
|
|
143
|
+
'--cm-editor-actions-content-min-height',
|
|
144
|
+
`${container.offsetHeight + SPACER_BOTTOM_GAP}px`,
|
|
145
|
+
);
|
|
146
|
+
this.align(view);
|
|
147
|
+
});
|
|
148
|
+
this.resizeObserver.observe(container);
|
|
149
|
+
this.dom = container;
|
|
150
|
+
this.mounted = callbacksRef;
|
|
151
|
+
callbacksRef?.onMount(container);
|
|
152
|
+
this.align(view);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private align(view: EditorView): void {
|
|
156
|
+
view.requestMeasure<{ top: number; right: number } | null>({
|
|
157
|
+
read: () => {
|
|
158
|
+
if (!this.dom) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const editorRect = view.dom.getBoundingClientRect();
|
|
162
|
+
const spacer = view.dom.querySelector('.cm-editor-actions-spacer');
|
|
163
|
+
let topClient: number;
|
|
164
|
+
let rightClient: number;
|
|
165
|
+
if (spacer) {
|
|
166
|
+
const rect = spacer.getBoundingClientRect();
|
|
167
|
+
topClient = rect.top;
|
|
168
|
+
rightClient = rect.right;
|
|
169
|
+
} else {
|
|
170
|
+
const rect = view.contentDOM.getBoundingClientRect();
|
|
171
|
+
const style = window.getComputedStyle(view.contentDOM);
|
|
172
|
+
topClient = rect.top + (parseFloat(style.paddingTop) || 0);
|
|
173
|
+
rightClient = rect.right - (parseFloat(style.paddingRight) || 0);
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
top: topClient - editorRect.top + view.scrollDOM.scrollTop,
|
|
177
|
+
right: editorRect.right - rightClient - view.scrollDOM.scrollLeft,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
write: (pos) => {
|
|
181
|
+
if (!pos || !this.dom) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
view.dom.style.setProperty(
|
|
185
|
+
'--cm-editor-actions-top',
|
|
186
|
+
`${pos.top}px`,
|
|
187
|
+
);
|
|
188
|
+
view.dom.style.setProperty(
|
|
189
|
+
'--cm-editor-actions-right',
|
|
190
|
+
`${pos.right}px`,
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private unmount(): void {
|
|
197
|
+
if (!this.dom) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
this.resizeObserver?.disconnect();
|
|
201
|
+
this.resizeObserver = null;
|
|
202
|
+
this.mounted?.onUnmount();
|
|
203
|
+
this.mounted = null;
|
|
204
|
+
const root = this.dom?.parentElement;
|
|
205
|
+
if (root) {
|
|
206
|
+
root.style.removeProperty('--cm-editor-actions-width');
|
|
207
|
+
root.style.removeProperty('--cm-editor-actions-height');
|
|
208
|
+
root.style.removeProperty('--cm-editor-actions-content-min-height');
|
|
209
|
+
root.style.removeProperty('--cm-editor-actions-top');
|
|
210
|
+
root.style.removeProperty('--cm-editor-actions-right');
|
|
211
|
+
}
|
|
212
|
+
this.dom?.remove();
|
|
213
|
+
this.dom = null;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const theme = EditorView.theme({
|
|
219
|
+
'.cm-content': {
|
|
220
|
+
minHeight: 'var(--cm-editor-actions-content-min-height, 0px)',
|
|
221
|
+
},
|
|
222
|
+
'.cm-editor-actions-spacer': {
|
|
223
|
+
float: 'right',
|
|
224
|
+
boxSizing: 'content-box',
|
|
225
|
+
width: 'var(--cm-editor-actions-width, 0px)',
|
|
226
|
+
height: 'var(--cm-editor-actions-height, 0px)',
|
|
227
|
+
paddingLeft: `${SPACER_LEFT_GAP}px`,
|
|
228
|
+
paddingBottom: `${SPACER_BOTTOM_GAP}px`,
|
|
229
|
+
userSelect: 'none',
|
|
230
|
+
WebkitUserSelect: 'none',
|
|
231
|
+
pointerEvents: 'none',
|
|
232
|
+
},
|
|
233
|
+
'.cm-editor-actions': {
|
|
234
|
+
position: 'absolute',
|
|
235
|
+
top: 'var(--cm-editor-actions-top, 0px)',
|
|
236
|
+
right: 'var(--cm-editor-actions-right, 0px)',
|
|
237
|
+
zIndex: '2',
|
|
238
|
+
userSelect: 'none',
|
|
239
|
+
WebkitUserSelect: 'none',
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
extension: [activeField, spacer, overlay, theme],
|
|
245
|
+
set: (callbacks) => {
|
|
246
|
+
callbacksRef = callbacks;
|
|
247
|
+
return setActiveEffect.of(callbacks !== null);
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle callbacks for host-rendered overlay content (inline panels, editor
|
|
3
|
+
* action buttons, …). The host renders into the provided DOM container (e.g.
|
|
4
|
+
* via React `createPortal`) and is expected to clean up on unmount.
|
|
5
|
+
*/
|
|
6
|
+
export type HostPortalCallbacks = {
|
|
7
|
+
onMount: (container: HTMLElement) => void;
|
|
8
|
+
onUnmount: () => void;
|
|
9
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export * as LanguageSupport from '@neo4j-cypher/language-support';
|
|
2
2
|
export { CypherEditor } from './CypherEditor';
|
|
3
3
|
export type { InlinePanelProps } from './CypherEditor';
|
|
4
|
+
export type { DiffProps } from './diffView';
|
|
4
5
|
export { cypher } from './lang-cypher/langCypher';
|
|
5
6
|
export { darkThemeConstants, lightThemeConstants } from './themes';
|
package/src/inlinePanel.ts
CHANGED
|
@@ -6,15 +6,10 @@ import {
|
|
|
6
6
|
WidgetType,
|
|
7
7
|
} from '@codemirror/view';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*/
|
|
14
|
-
export type InlinePanelCallbacks = {
|
|
15
|
-
onMount: (container: HTMLElement) => void;
|
|
16
|
-
onUnmount: () => void;
|
|
17
|
-
};
|
|
9
|
+
import type { HostPortalCallbacks } from './hostCallbacks';
|
|
10
|
+
|
|
11
|
+
/** Lifecycle callbacks for an inline panel. See {@link HostPortalCallbacks}. */
|
|
12
|
+
export type InlinePanelCallbacks = HostPortalCallbacks;
|
|
18
13
|
|
|
19
14
|
export type InlinePanelShowOptions = {
|
|
20
15
|
/** Document position the panel anchors to. */
|
|
@@ -79,32 +74,57 @@ export function createInlinePanelController(): InlinePanelController {
|
|
|
79
74
|
}
|
|
80
75
|
}
|
|
81
76
|
|
|
77
|
+
const buildDecoration = (options: InlinePanelShowOptions): DecorationSet =>
|
|
78
|
+
Decoration.set([
|
|
79
|
+
Decoration.widget({
|
|
80
|
+
widget: new InlinePanelWidget(options),
|
|
81
|
+
block: true,
|
|
82
|
+
side: options.placement === 'below' ? 1 : -1,
|
|
83
|
+
}).range(options.pos),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const readOptions = (
|
|
87
|
+
decorations: DecorationSet,
|
|
88
|
+
): InlinePanelShowOptions | null => {
|
|
89
|
+
const widget = decorations.iter().value?.spec?.widget;
|
|
90
|
+
return widget instanceof InlinePanelWidget ? widget.options : null;
|
|
91
|
+
};
|
|
92
|
+
|
|
82
93
|
const field = StateField.define<DecorationSet>({
|
|
83
94
|
create() {
|
|
84
95
|
return Decoration.none;
|
|
85
96
|
},
|
|
86
97
|
update(decorations, transaction) {
|
|
87
|
-
let next: DecorationSet | null = null;
|
|
88
98
|
for (const effect of transaction.effects) {
|
|
89
99
|
if (effect.is(showEffect)) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const side = effect.value.placement === 'below' ? 1 : -1;
|
|
94
|
-
next = Decoration.set([
|
|
95
|
-
Decoration.widget({
|
|
96
|
-
widget: new InlinePanelWidget(effect.value),
|
|
97
|
-
block: true,
|
|
98
|
-
side,
|
|
99
|
-
}).range(effect.value.pos),
|
|
100
|
-
]);
|
|
101
|
-
}
|
|
100
|
+
return effect.value === null
|
|
101
|
+
? Decoration.none
|
|
102
|
+
: buildDecoration(effect.value);
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
|
|
106
|
+
// Nothing to do when the panel is closed, or when the document is unchanged
|
|
107
|
+
if (decorations.size === 0 || !transaction.docChanged) {
|
|
108
|
+
return decorations.map(transaction.changes);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Re-anchor manually because CodeMirror's default mapping silently drops
|
|
112
|
+
// block-widget decorations when their anchor line is deleted
|
|
113
|
+
const options = readOptions(decorations);
|
|
114
|
+
if (options === null) {
|
|
115
|
+
return decorations.map(transaction.changes);
|
|
106
116
|
}
|
|
107
|
-
|
|
117
|
+
const side = options.placement === 'below' ? 1 : -1;
|
|
118
|
+
const lineNumber = Math.min(
|
|
119
|
+
transaction.startState.doc.lineAt(options.pos).number,
|
|
120
|
+
transaction.newDoc.lines,
|
|
121
|
+
);
|
|
122
|
+
const line = transaction.newDoc.line(lineNumber);
|
|
123
|
+
|
|
124
|
+
return buildDecoration({
|
|
125
|
+
...options,
|
|
126
|
+
pos: side === 1 ? line.to : line.from,
|
|
127
|
+
});
|
|
108
128
|
},
|
|
109
129
|
provide: (f) => EditorView.decorations.from(f),
|
|
110
130
|
});
|
|
@@ -18,6 +18,17 @@ import {
|
|
|
18
18
|
upArrowSvg,
|
|
19
19
|
} from './themeIcons';
|
|
20
20
|
|
|
21
|
+
export interface DiffColors {
|
|
22
|
+
/** Background fill for the whole inserted/changed line. */
|
|
23
|
+
insertedLine: string;
|
|
24
|
+
/** Highlight for the exact inserted text within a changed line. */
|
|
25
|
+
insertedText: string;
|
|
26
|
+
/** Background fill for the whole deleted line widget. */
|
|
27
|
+
deletedLine: string;
|
|
28
|
+
/** Highlight for the exact deleted text within a deleted chunk. */
|
|
29
|
+
deletedText: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
export interface ThemeOptions {
|
|
22
33
|
dark: boolean;
|
|
23
34
|
editorSettings: {
|
|
@@ -40,6 +51,7 @@ export interface ThemeOptions {
|
|
|
40
51
|
};
|
|
41
52
|
highlightStyles: Partial<Record<HighlightedCypherTokenTypes, string>>;
|
|
42
53
|
inheritBgColor?: boolean;
|
|
54
|
+
diffColors?: DiffColors;
|
|
43
55
|
}
|
|
44
56
|
|
|
45
57
|
export const createCypherTheme = ({
|
|
@@ -47,6 +59,7 @@ export const createCypherTheme = ({
|
|
|
47
59
|
editorSettings: settings,
|
|
48
60
|
highlightStyles,
|
|
49
61
|
inheritBgColor,
|
|
62
|
+
diffColors,
|
|
50
63
|
}: ThemeOptions): Extension => {
|
|
51
64
|
const themeOptions: Record<string, StyleSpec> = {
|
|
52
65
|
'&': {
|
|
@@ -218,6 +231,26 @@ export const createCypherTheme = ({
|
|
|
218
231
|
},
|
|
219
232
|
},
|
|
220
233
|
},
|
|
234
|
+
...(diffColors && {
|
|
235
|
+
'&.cm-merge-b .cm-changedLine': {
|
|
236
|
+
backgroundColor: diffColors.insertedLine,
|
|
237
|
+
},
|
|
238
|
+
'&.cm-merge-b .cm-changedText': {
|
|
239
|
+
background: diffColors.insertedText,
|
|
240
|
+
},
|
|
241
|
+
'&.cm-merge-b .cm-deletedChunk': {
|
|
242
|
+
backgroundColor: diffColors.deletedLine,
|
|
243
|
+
},
|
|
244
|
+
'&.cm-merge-b .cm-deletedChunk .cm-deletedText': {
|
|
245
|
+
background: diffColors.deletedText,
|
|
246
|
+
},
|
|
247
|
+
// Hide the empty deletion widget that unifiedMergeView renders
|
|
248
|
+
// when the original document is empty (its only line is an empty <del>).
|
|
249
|
+
'&.cm-merge-b .cm-deletedChunk:has(> .cm-deletedLine:only-child > del > br:only-child)':
|
|
250
|
+
{
|
|
251
|
+
display: 'none',
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
221
254
|
};
|
|
222
255
|
|
|
223
256
|
const themeExtension = EditorView.theme(themeOptions, { dark });
|