@neo4j-cypher/react-codemirror 2.0.0-next.34 → 2.0.0-next.36

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.34",
3
+ "version": "2.0.0-next.36",
4
4
  "keywords": [
5
5
  "codemirror",
6
6
  "codemirror 6",
@@ -47,8 +47,8 @@
47
47
  "style-mod": "^4.1.2",
48
48
  "vscode-languageserver-types": "^3.17.3",
49
49
  "workerpool": "^9.3.3",
50
- "@neo4j-cypher/language-support": "2.0.0-next.31",
51
- "@neo4j-cypher/lint-worker": "1.10.1-next.8"
50
+ "@neo4j-cypher/language-support": "2.0.0-next.33",
51
+ "@neo4j-cypher/lint-worker": "1.10.1-next.10"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@neo4j-ndl/base": "^3.2.10",
@@ -13,6 +13,11 @@ 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';
16
21
  import {
17
22
  formatQuery,
18
23
  CypherLanguageService,
@@ -180,8 +185,26 @@ export interface CypherEditorProps {
180
185
  * @default false
181
186
  */
182
187
  moveFocusOnTab?: boolean;
188
+ /**
189
+ * Render a panel as a block widget inside the editor.
190
+ * The widget DOM is only rebuilt when `pos` or `placement` change
191
+ */
192
+ inlinePanel?: InlinePanelProps | null;
183
193
  }
184
194
 
195
+ export type InlinePanelProps = {
196
+ /**
197
+ * Position the panel anchors to.
198
+ */
199
+ pos: number;
200
+ /**
201
+ * Whether to render above or below the line
202
+ *
203
+ * @default 'above'
204
+ */
205
+ placement?: 'above' | 'below';
206
+ } & InlinePanelCallbacks;
207
+
185
208
  const format = (view: EditorView): void => {
186
209
  try {
187
210
  const doc = view.state.doc.toString();
@@ -290,10 +313,12 @@ class CodemirrorSymbolFetcher {
290
313
  }
291
314
  private languageService: CypherLanguageService;
292
315
  private processing = false;
293
- private nextJob: {
294
- query: string;
295
- schema: DbSchema;
296
- };
316
+ private nextJob:
317
+ | {
318
+ query: string;
319
+ schema: DbSchema;
320
+ }
321
+ | undefined;
297
322
  private symbolTablePool = workerpool.pool(WorkerURL, {
298
323
  minWorkers: 1,
299
324
  workerOpts: { type: 'module' },
@@ -316,11 +341,11 @@ class CodemirrorSymbolFetcher {
316
341
  this.processing = true;
317
342
  while (this.nextJob) {
318
343
  try {
319
- const proxyWorker =
320
- (await this.symbolTablePool.proxy()) as unknown as LintWorker;
321
344
  const query = this.nextJob.query;
322
345
  const dbSchema = this.nextJob.schema;
323
346
  this.nextJob = undefined;
347
+ const proxyWorker =
348
+ (await this.symbolTablePool.proxy()) as unknown as LintWorker;
324
349
 
325
350
  const result = await proxyWorker.lintCypherQuery(query, dbSchema);
326
351
 
@@ -360,6 +385,7 @@ export class CypherEditor extends Component<
360
385
  */
361
386
  editorView: React.MutableRefObject<EditorView> = createRef();
362
387
  private schemaRef: React.MutableRefObject<CypherConfig> = createRef();
388
+ private inlinePanelController: InlinePanelController | null = null;
363
389
 
364
390
  /**
365
391
  * Format Cypher query
@@ -471,6 +497,8 @@ export class CypherEditor extends Component<
471
497
  overrideThemeBackgroundColor,
472
498
  );
473
499
 
500
+ this.inlinePanelController = createInlinePanelController();
501
+
474
502
  const changeListener = this.debouncedOnChange
475
503
  ? [
476
504
  EditorView.updateListener.of((upt: ViewUpdate) => {
@@ -536,6 +564,7 @@ export class CypherEditor extends Component<
536
564
  'Press Escape to leave the editor and continue tabbing through the page',
537
565
  })
538
566
  : [],
567
+ this.inlinePanelController.extension,
539
568
  ],
540
569
  doc: this.props.value,
541
570
  });
@@ -553,6 +582,53 @@ export class CypherEditor extends Component<
553
582
  } else if (this.props.offset) {
554
583
  this.updateCursorPosition(this.props.offset);
555
584
  }
585
+
586
+ if (this.props.inlinePanel) {
587
+ this.openInlinePanel(this.props.inlinePanel);
588
+ }
589
+ }
590
+
591
+ private openInlinePanel(
592
+ props: NonNullable<CypherEditorProps['inlinePanel']>,
593
+ ): void {
594
+ const view = this.editorView.current;
595
+ const controller = this.inlinePanelController;
596
+ if (!view || !controller) {
597
+ return;
598
+ }
599
+
600
+ const pos = Math.max(0, Math.min(props.pos, view.state.doc.length));
601
+ const line = view.state.doc.lineAt(pos);
602
+ controller.updateCallbacks({
603
+ onMount: props.onMount,
604
+ onUnmount: props.onUnmount,
605
+ });
606
+ view.dispatch({
607
+ effects: controller.show({
608
+ pos: props.placement === 'below' ? line.to : line.from,
609
+ placement: props.placement,
610
+ onMount: props.onMount,
611
+ onUnmount: props.onUnmount,
612
+ }),
613
+ });
614
+ }
615
+
616
+ private updateInlinePanel(
617
+ props: NonNullable<CypherEditorProps['inlinePanel']>,
618
+ ): void {
619
+ this.inlinePanelController?.updateCallbacks({
620
+ onMount: props.onMount,
621
+ onUnmount: props.onUnmount,
622
+ });
623
+ }
624
+
625
+ private closeInlinePanel(): void {
626
+ const view = this.editorView.current;
627
+ const controller = this.inlinePanelController;
628
+ if (!view || !controller) {
629
+ return;
630
+ }
631
+ view.dispatch({ effects: controller.hide() });
556
632
  }
557
633
 
558
634
  componentDidUpdate(prevProps: CypherEditorProps): void {
@@ -642,6 +718,22 @@ export class CypherEditor extends Component<
642
718
  });
643
719
  }
644
720
 
721
+ const prevPanel = prevProps.inlinePanel;
722
+ const nextPanel = this.props.inlinePanel;
723
+ if (prevPanel !== nextPanel) {
724
+ if (!nextPanel) {
725
+ this.closeInlinePanel();
726
+ } else if (
727
+ !prevPanel ||
728
+ prevPanel.pos !== nextPanel.pos ||
729
+ prevPanel.placement !== nextPanel.placement
730
+ ) {
731
+ this.openInlinePanel(nextPanel);
732
+ } else {
733
+ this.updateInlinePanel(nextPanel);
734
+ }
735
+ }
736
+
645
737
  if (prevProps.domEventHandlers !== this.props.domEventHandlers) {
646
738
  this.editorView.current.dispatch({
647
739
  effects: domEventHandlerCompartment.reconfigure(
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * as LanguageSupport from '@neo4j-cypher/language-support';
2
2
  export { CypherEditor } from './CypherEditor';
3
+ export type { InlinePanelProps } from './CypherEditor';
3
4
  export { cypher } from './lang-cypher/langCypher';
4
5
  export { darkThemeConstants, lightThemeConstants } from './themes';
@@ -0,0 +1,120 @@
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 field = StateField.define<DecorationSet>({
83
+ create() {
84
+ return Decoration.none;
85
+ },
86
+ update(decorations, transaction) {
87
+ let next: DecorationSet | null = null;
88
+ for (const effect of transaction.effects) {
89
+ 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
+ }
102
+ }
103
+ }
104
+ if (next !== null) {
105
+ return next;
106
+ }
107
+ return decorations.map(transaction.changes);
108
+ },
109
+ provide: (f) => EditorView.decorations.from(f),
110
+ });
111
+
112
+ return {
113
+ extension: field,
114
+ show: (options) => showEffect.of(options),
115
+ hide: () => showEffect.of(null),
116
+ updateCallbacks: (callbacks) => {
117
+ callbacksRef = callbacks;
118
+ },
119
+ };
120
+ }