@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/src/CypherEditor.d.ts +18 -2
  3. package/dist/src/CypherEditor.js +41 -1
  4. package/dist/src/CypherEditor.js.map +1 -1
  5. package/dist/src/diffView.d.ts +13 -0
  6. package/dist/src/diffView.js +11 -0
  7. package/dist/src/diffView.js.map +1 -0
  8. package/dist/src/e2e_tests/syntaxValidation.spec.js +0 -3
  9. package/dist/src/e2e_tests/syntaxValidation.spec.js.map +1 -1
  10. package/dist/src/editorActions.d.ts +17 -0
  11. package/dist/src/editorActions.js +191 -0
  12. package/dist/src/editorActions.js.map +1 -0
  13. package/dist/src/hostCallbacks.d.ts +9 -0
  14. package/dist/src/hostCallbacks.js +2 -0
  15. package/dist/src/hostCallbacks.js.map +1 -0
  16. package/dist/src/index.d.ts +1 -0
  17. package/dist/src/index.js.map +1 -1
  18. package/dist/src/inlinePanel.d.ts +3 -9
  19. package/dist/src/inlinePanel.js +30 -17
  20. package/dist/src/inlinePanel.js.map +1 -1
  21. package/dist/src/lang-cypher/createCypherTheme.d.ts +12 -1
  22. package/dist/src/lang-cypher/createCypherTheme.js +20 -1
  23. package/dist/src/lang-cypher/createCypherTheme.js.map +1 -1
  24. package/dist/src/lang-cypher/lintWorker.mjs +222 -221
  25. package/dist/src/themes.js +14 -0
  26. package/dist/src/themes.js.map +1 -1
  27. package/dist/tsconfig.tsbuildinfo +1 -1
  28. package/package.json +6 -5
  29. package/src/CypherEditor.tsx +75 -3
  30. package/src/diffView.ts +24 -0
  31. package/src/e2e_tests/syntaxValidation.spec.tsx +0 -15
  32. package/src/editorActions.ts +250 -0
  33. package/src/hostCallbacks.ts +9 -0
  34. package/src/index.ts +1 -0
  35. package/src/inlinePanel.ts +45 -25
  36. package/src/lang-cypher/createCypherTheme.ts +33 -0
  37. package/src/lang-cypher/lintWorker.mjs +222 -221
  38. 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.36",
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.33",
51
- "@neo4j-cypher/lint-worker": "1.10.1-next.10"
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/",
@@ -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 = { cypherSupportEnabled: boolean };
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
  }
@@ -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';
@@ -6,15 +6,10 @@ import {
6
6
  WidgetType,
7
7
  } from '@codemirror/view';
8
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
- };
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
- if (effect.value === null) {
91
- next = Decoration.none;
92
- } else {
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
- if (next !== null) {
105
- return next;
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
- return decorations.map(transaction.changes);
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 });