@neo4j-cypher/react-codemirror 2.0.0-next.37 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neo4j-cypher/react-codemirror",
3
- "version": "2.0.0-next.37",
3
+ "version": "2.0.0-next.38",
4
4
  "keywords": [
5
5
  "codemirror",
6
6
  "codemirror 6",
@@ -48,8 +48,8 @@
48
48
  "style-mod": "^4.1.2",
49
49
  "vscode-languageserver-types": "^3.17.3",
50
50
  "workerpool": "^9.3.3",
51
- "@neo4j-cypher/language-support": "2.0.0-next.34",
52
- "@neo4j-cypher/lint-worker": "1.10.1-next.11"
51
+ "@neo4j-cypher/language-support": "2.0.0-next.35",
52
+ "@neo4j-cypher/lint-worker": "1.10.1-next.12"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@neo4j-ndl/base": "^3.2.10",
@@ -68,12 +68,12 @@
68
68
  "vite": "^4.5.10"
69
69
  },
70
70
  "peerDependencies": {
71
- "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"
72
73
  },
73
74
  "engines": {
74
75
  "node": ">=24.11.1"
75
76
  },
76
- "engineStrict": true,
77
77
  "scripts": {
78
78
  "dev": "tsc --watch",
79
79
  "build": "pnpm copy-lint-worker && tsc --declaration --outDir dist/",
@@ -19,13 +19,19 @@ import {
19
19
  type InlinePanelController,
20
20
  } from './inlinePanel';
21
21
  import { createDiffExtension, type DiffProps } from './diffView';
22
+ import {
23
+ createEditorActionsController,
24
+ type EditorActionsCallbacks,
25
+ type EditorActionsController,
26
+ } from './editorActions';
22
27
  import {
23
28
  formatQuery,
24
29
  CypherLanguageService,
25
30
  type DbSchema,
26
31
  } from '@neo4j-cypher/language-support';
27
32
  import debounce from 'lodash.debounce';
28
- import { Component, createRef } from 'react';
33
+ import { Component, createRef, type ReactNode } from 'react';
34
+ import { createPortal } from 'react-dom';
29
35
  import { DEBOUNCE_TIME } from './constants';
30
36
  import {
31
37
  replaceHistory,
@@ -196,6 +202,12 @@ export interface CypherEditorProps {
196
202
  * Deleted lines are shown as uneditable widgets.
197
203
  */
198
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;
199
211
  }
200
212
 
201
213
  export type InlinePanelProps = {
@@ -308,7 +320,9 @@ const formatLineNumber =
308
320
  return a.toString();
309
321
  };
310
322
 
311
- type CypherEditorState = { cypherSupportEnabled: boolean };
323
+ type CypherEditorState = {
324
+ editorActionsContainer: HTMLElement | null;
325
+ };
312
326
 
313
327
  const ExternalEdit = Annotation.define<boolean>();
314
328
  const WorkerURL = new URL('./lang-cypher/lintWorker.mjs', import.meta.url)
@@ -393,6 +407,17 @@ export class CypherEditor extends Component<
393
407
  editorView: React.MutableRefObject<EditorView> = createRef();
394
408
  private schemaRef: React.MutableRefObject<CypherConfig> = createRef();
395
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
+ };
396
421
 
397
422
  /**
398
423
  * Format Cypher query
@@ -505,6 +530,7 @@ export class CypherEditor extends Component<
505
530
  );
506
531
 
507
532
  this.inlinePanelController = createInlinePanelController();
533
+ this.editorActionsController = createEditorActionsController();
508
534
 
509
535
  const changeListener = this.debouncedOnChange
510
536
  ? [
@@ -575,6 +601,7 @@ export class CypherEditor extends Component<
575
601
  diffCompartment.of(
576
602
  this.props.diff ? createDiffExtension(this.props.diff) : [],
577
603
  ),
604
+ this.editorActionsController.extension,
578
605
  ],
579
606
  doc: this.props.value,
580
607
  });
@@ -596,6 +623,22 @@ export class CypherEditor extends Component<
596
623
  if (this.props.inlinePanel) {
597
624
  this.openInlinePanel(this.props.inlinePanel);
598
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
+ });
599
642
  }
600
643
 
601
644
  private openInlinePanel(
@@ -752,6 +795,12 @@ export class CypherEditor extends Component<
752
795
  });
753
796
  }
754
797
 
798
+ const hadActions = prevProps.editorActions != null;
799
+ const hasActions = this.props.editorActions != null;
800
+ if (hadActions !== hasActions) {
801
+ this.setEditorActions(hasActions);
802
+ }
803
+
755
804
  if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
756
805
  this.editorView.current.dispatch({
757
806
  effects: domEventHandlerCompartment.reconfigure(
@@ -796,11 +845,16 @@ export class CypherEditor extends Component<
796
845
  const themeClass =
797
846
  typeof theme === 'string' ? `cm-theme-${theme}` : 'cm-theme';
798
847
 
848
+ const { editorActionsContainer } = this.state;
849
+
799
850
  return (
800
851
  <div
801
852
  ref={this.editorContainer}
802
853
  className={`${themeClass}${className ? ` ${className}` : ''}`}
803
- />
854
+ >
855
+ {editorActionsContainer &&
856
+ createPortal(this.props.editorActions, editorActionsContainer)}
857
+ </div>
804
858
  );
805
859
  }
806
860
  }
@@ -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
+ };
@@ -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. */