@neo4j-cypher/react-codemirror 2.0.0-next.35 → 2.0.0-next.37

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neo4j-cypher/react-codemirror",
3
- "version": "2.0.0-next.35",
3
+ "version": "2.0.0-next.37",
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/lint-worker": "1.10.1-next.9",
51
- "@neo4j-cypher/language-support": "2.0.0-next.32"
51
+ "@neo4j-cypher/language-support": "2.0.0-next.34",
52
+ "@neo4j-cypher/lint-worker": "1.10.1-next.11"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@neo4j-ndl/base": "^3.2.10",
@@ -13,6 +13,12 @@ import {
13
13
  placeholder,
14
14
  ViewUpdate,
15
15
  } from '@codemirror/view';
16
+ import {
17
+ createInlinePanelController,
18
+ type InlinePanelCallbacks,
19
+ type InlinePanelController,
20
+ } from './inlinePanel';
21
+ import { createDiffExtension, type DiffProps } from './diffView';
16
22
  import {
17
23
  formatQuery,
18
24
  CypherLanguageService,
@@ -180,8 +186,31 @@ export interface CypherEditorProps {
180
186
  * @default false
181
187
  */
182
188
  moveFocusOnTab?: boolean;
189
+ /**
190
+ * Render a panel as a block widget inside the editor.
191
+ * The widget DOM is only rebuilt when `pos` or `placement` change
192
+ */
193
+ inlinePanel?: InlinePanelProps | null;
194
+ /**
195
+ * Render a unified diff of the current document against `diff.original`.
196
+ * Deleted lines are shown as uneditable widgets.
197
+ */
198
+ diff?: DiffProps | null;
183
199
  }
184
200
 
201
+ export type InlinePanelProps = {
202
+ /**
203
+ * Position the panel anchors to.
204
+ */
205
+ pos: number;
206
+ /**
207
+ * Whether to render above or below the line
208
+ *
209
+ * @default 'above'
210
+ */
211
+ placement?: 'above' | 'below';
212
+ } & InlinePanelCallbacks;
213
+
185
214
  const format = (view: EditorView): void => {
186
215
  try {
187
216
  const doc = view.state.doc.toString();
@@ -268,6 +297,7 @@ const lineNumbersCompartment = new Compartment();
268
297
  const readOnlyCompartment = new Compartment();
269
298
  const placeholderCompartment = new Compartment();
270
299
  const domEventHandlerCompartment = new Compartment();
300
+ const diffCompartment = new Compartment();
271
301
 
272
302
  const formatLineNumber =
273
303
  (prompt?: string) => (a: number, state: EditorState) => {
@@ -290,10 +320,12 @@ class CodemirrorSymbolFetcher {
290
320
  }
291
321
  private languageService: CypherLanguageService;
292
322
  private processing = false;
293
- private nextJob: {
294
- query: string;
295
- schema: DbSchema;
296
- };
323
+ private nextJob:
324
+ | {
325
+ query: string;
326
+ schema: DbSchema;
327
+ }
328
+ | undefined;
297
329
  private symbolTablePool = workerpool.pool(WorkerURL, {
298
330
  minWorkers: 1,
299
331
  workerOpts: { type: 'module' },
@@ -316,11 +348,11 @@ class CodemirrorSymbolFetcher {
316
348
  this.processing = true;
317
349
  while (this.nextJob) {
318
350
  try {
319
- const proxyWorker =
320
- (await this.symbolTablePool.proxy()) as unknown as LintWorker;
321
351
  const query = this.nextJob.query;
322
352
  const dbSchema = this.nextJob.schema;
323
353
  this.nextJob = undefined;
354
+ const proxyWorker =
355
+ (await this.symbolTablePool.proxy()) as unknown as LintWorker;
324
356
 
325
357
  const result = await proxyWorker.lintCypherQuery(query, dbSchema);
326
358
 
@@ -360,6 +392,7 @@ export class CypherEditor extends Component<
360
392
  */
361
393
  editorView: React.MutableRefObject<EditorView> = createRef();
362
394
  private schemaRef: React.MutableRefObject<CypherConfig> = createRef();
395
+ private inlinePanelController: InlinePanelController | null = null;
363
396
 
364
397
  /**
365
398
  * Format Cypher query
@@ -471,6 +504,8 @@ export class CypherEditor extends Component<
471
504
  overrideThemeBackgroundColor,
472
505
  );
473
506
 
507
+ this.inlinePanelController = createInlinePanelController();
508
+
474
509
  const changeListener = this.debouncedOnChange
475
510
  ? [
476
511
  EditorView.updateListener.of((upt: ViewUpdate) => {
@@ -536,6 +571,10 @@ export class CypherEditor extends Component<
536
571
  'Press Escape to leave the editor and continue tabbing through the page',
537
572
  })
538
573
  : [],
574
+ this.inlinePanelController.extension,
575
+ diffCompartment.of(
576
+ this.props.diff ? createDiffExtension(this.props.diff) : [],
577
+ ),
539
578
  ],
540
579
  doc: this.props.value,
541
580
  });
@@ -553,6 +592,53 @@ export class CypherEditor extends Component<
553
592
  } else if (this.props.offset) {
554
593
  this.updateCursorPosition(this.props.offset);
555
594
  }
595
+
596
+ if (this.props.inlinePanel) {
597
+ this.openInlinePanel(this.props.inlinePanel);
598
+ }
599
+ }
600
+
601
+ private openInlinePanel(
602
+ props: NonNullable<CypherEditorProps['inlinePanel']>,
603
+ ): void {
604
+ const view = this.editorView.current;
605
+ const controller = this.inlinePanelController;
606
+ if (!view || !controller) {
607
+ return;
608
+ }
609
+
610
+ const pos = Math.max(0, Math.min(props.pos, view.state.doc.length));
611
+ const line = view.state.doc.lineAt(pos);
612
+ controller.updateCallbacks({
613
+ onMount: props.onMount,
614
+ onUnmount: props.onUnmount,
615
+ });
616
+ view.dispatch({
617
+ effects: controller.show({
618
+ pos: props.placement === 'below' ? line.to : line.from,
619
+ placement: props.placement,
620
+ onMount: props.onMount,
621
+ onUnmount: props.onUnmount,
622
+ }),
623
+ });
624
+ }
625
+
626
+ private updateInlinePanel(
627
+ props: NonNullable<CypherEditorProps['inlinePanel']>,
628
+ ): void {
629
+ this.inlinePanelController?.updateCallbacks({
630
+ onMount: props.onMount,
631
+ onUnmount: props.onUnmount,
632
+ });
633
+ }
634
+
635
+ private closeInlinePanel(): void {
636
+ const view = this.editorView.current;
637
+ const controller = this.inlinePanelController;
638
+ if (!view || !controller) {
639
+ return;
640
+ }
641
+ view.dispatch({ effects: controller.hide() });
556
642
  }
557
643
 
558
644
  componentDidUpdate(prevProps: CypherEditorProps): void {
@@ -642,6 +728,30 @@ export class CypherEditor extends Component<
642
728
  });
643
729
  }
644
730
 
731
+ const prevPanel = prevProps.inlinePanel;
732
+ const nextPanel = this.props.inlinePanel;
733
+ if (prevPanel !== nextPanel) {
734
+ if (!nextPanel) {
735
+ this.closeInlinePanel();
736
+ } else if (
737
+ !prevPanel ||
738
+ prevPanel.pos !== nextPanel.pos ||
739
+ prevPanel.placement !== nextPanel.placement
740
+ ) {
741
+ this.openInlinePanel(nextPanel);
742
+ } else {
743
+ this.updateInlinePanel(nextPanel);
744
+ }
745
+ }
746
+
747
+ if (prevProps.diff?.original !== this.props.diff?.original) {
748
+ this.editorView.current.dispatch({
749
+ effects: diffCompartment.reconfigure(
750
+ this.props.diff ? createDiffExtension(this.props.diff) : [],
751
+ ),
752
+ });
753
+ }
754
+
645
755
  if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
646
756
  this.editorView.current.dispatch({
647
757
  effects: domEventHandlerCompartment.reconfigure(
@@ -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.",
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export * as LanguageSupport from '@neo4j-cypher/language-support';
2
2
  export { CypherEditor } from './CypherEditor';
3
+ export type { InlinePanelProps } from './CypherEditor';
4
+ export type { DiffProps } from './diffView';
3
5
  export { cypher } from './lang-cypher/langCypher';
4
6
  export { darkThemeConstants, lightThemeConstants } from './themes';
@@ -0,0 +1,145 @@
1
+ import { StateEffect, StateField, type Extension } from '@codemirror/state';
2
+ import {
3
+ Decoration,
4
+ type DecorationSet,
5
+ EditorView,
6
+ WidgetType,
7
+ } from '@codemirror/view';
8
+
9
+ /**
10
+ * Lifecycle callbacks for an inline panel. The host renders into the
11
+ * provided DOM container (e.g. via React `createPortal`) and is expected
12
+ * to clean up on unmount.
13
+ */
14
+ export type InlinePanelCallbacks = {
15
+ onMount: (container: HTMLElement) => void;
16
+ onUnmount: () => void;
17
+ };
18
+
19
+ export type InlinePanelShowOptions = {
20
+ /** Document position the panel anchors to. */
21
+ pos: number;
22
+ /**
23
+ * Where to render the panel relative to the line at `pos`. `'above'`
24
+ * places it before the line, `'below'` after it.
25
+ *
26
+ * @default 'above'
27
+ */
28
+ placement?: 'above' | 'below';
29
+ } & InlinePanelCallbacks;
30
+
31
+ type ShowPayload = InlinePanelShowOptions | null;
32
+
33
+ export type InlinePanelController = {
34
+ /** CodeMirror extension to register on the editor. */
35
+ extension: Extension;
36
+ /** Build an effect that mounts the panel with the given options. */
37
+ show: (options: InlinePanelShowOptions) => StateEffect<ShowPayload>;
38
+ /** Build an effect that unmounts the panel. */
39
+ hide: () => StateEffect<ShowPayload>;
40
+ updateCallbacks: (callbacks: InlinePanelCallbacks) => void;
41
+ };
42
+
43
+ export function createInlinePanelController(): InlinePanelController {
44
+ const showEffect = StateEffect.define<ShowPayload>();
45
+
46
+ let callbacksRef: InlinePanelCallbacks | null = null;
47
+
48
+ class InlinePanelWidget extends WidgetType {
49
+ private resizeObserver: ResizeObserver | null = null;
50
+
51
+ constructor(readonly options: InlinePanelShowOptions) {
52
+ super();
53
+ }
54
+
55
+ toDOM(view: EditorView): HTMLElement {
56
+ const container = document.createElement('div');
57
+ container.className = 'cm-inline-panel';
58
+ this.resizeObserver = new ResizeObserver(() => view.requestMeasure());
59
+ this.resizeObserver.observe(container);
60
+ callbacksRef?.onMount(container);
61
+ return container;
62
+ }
63
+
64
+ destroy(): void {
65
+ this.resizeObserver?.disconnect();
66
+ this.resizeObserver = null;
67
+ callbacksRef?.onUnmount();
68
+ }
69
+
70
+ eq(other: InlinePanelWidget): boolean {
71
+ return (
72
+ other.options.pos === this.options.pos &&
73
+ other.options.placement === this.options.placement
74
+ );
75
+ }
76
+
77
+ ignoreEvent(): boolean {
78
+ return true;
79
+ }
80
+ }
81
+
82
+ const buildDecoration = (options: InlinePanelShowOptions): DecorationSet =>
83
+ Decoration.set([
84
+ Decoration.widget({
85
+ widget: new InlinePanelWidget(options),
86
+ block: true,
87
+ side: options.placement === 'below' ? 1 : -1,
88
+ }).range(options.pos),
89
+ ]);
90
+
91
+ const readOptions = (
92
+ decorations: DecorationSet,
93
+ ): InlinePanelShowOptions | null => {
94
+ const widget = decorations.iter().value?.spec?.widget;
95
+ return widget instanceof InlinePanelWidget ? widget.options : null;
96
+ };
97
+
98
+ const field = StateField.define<DecorationSet>({
99
+ create() {
100
+ return Decoration.none;
101
+ },
102
+ update(decorations, transaction) {
103
+ for (const effect of transaction.effects) {
104
+ if (effect.is(showEffect)) {
105
+ return effect.value === null
106
+ ? Decoration.none
107
+ : buildDecoration(effect.value);
108
+ }
109
+ }
110
+
111
+ // Nothing to do when the panel is closed, or when the document is unchanged
112
+ if (decorations.size === 0 || !transaction.docChanged) {
113
+ return decorations.map(transaction.changes);
114
+ }
115
+
116
+ // Re-anchor manually because CodeMirror's default mapping silently drops
117
+ // block-widget decorations when their anchor line is deleted
118
+ const options = readOptions(decorations);
119
+ if (options === null) {
120
+ return decorations.map(transaction.changes);
121
+ }
122
+ const side = options.placement === 'below' ? 1 : -1;
123
+ const lineNumber = Math.min(
124
+ transaction.startState.doc.lineAt(options.pos).number,
125
+ transaction.newDoc.lines,
126
+ );
127
+ const line = transaction.newDoc.line(lineNumber);
128
+
129
+ return buildDecoration({
130
+ ...options,
131
+ pos: side === 1 ? line.to : line.from,
132
+ });
133
+ },
134
+ provide: (f) => EditorView.decorations.from(f),
135
+ });
136
+
137
+ return {
138
+ extension: field,
139
+ show: (options) => showEffect.of(options),
140
+ hide: () => showEffect.of(null),
141
+ updateCallbacks: (callbacks) => {
142
+ callbacksRef = callbacks;
143
+ },
144
+ };
145
+ }
@@ -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 });