@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/CHANGELOG.md +10 -0
- package/dist/src/CypherEditor.d.ts +12 -2
- package/dist/src/CypherEditor.js +33 -1
- package/dist/src/CypherEditor.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/inlinePanel.d.ts +3 -9
- package/dist/src/inlinePanel.js.map +1 -1
- package/dist/src/lang-cypher/lintWorker.mjs +222 -221
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/CypherEditor.tsx +57 -3
- package/src/editorActions.ts +250 -0
- package/src/hostCallbacks.ts +9 -0
- package/src/inlinePanel.ts +4 -9
- package/src/lang-cypher/lintWorker.mjs +222 -221
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",
|
|
@@ -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.
|
|
52
|
-
"@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"
|
|
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/",
|
package/src/CypherEditor.tsx
CHANGED
|
@@ -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 = {
|
|
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
|
+
};
|
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. */
|