@dxos/react-ui-editor 0.8.1-staging.5be625a → 0.8.1-staging.9eaf14f
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/dist/lib/browser/index.mjs +276 -140
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node/index.cjs +310 -178
- package/dist/lib/node/index.cjs.map +4 -4
- package/dist/lib/node/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +276 -140
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/InputMode.stories.d.ts +2 -2
- package/dist/types/src/TextEditor.stories.d.ts +5 -40
- package/dist/types/src/TextEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/EditorToolbar/EditorToolbar.d.ts.map +1 -1
- package/dist/types/src/defaults.d.ts +2 -0
- package/dist/types/src/defaults.d.ts.map +1 -1
- package/dist/types/src/extensions/command/command.d.ts +4 -2
- package/dist/types/src/extensions/command/command.d.ts.map +1 -1
- package/dist/types/src/extensions/command/hint.d.ts.map +1 -1
- package/dist/types/src/extensions/command/menu.d.ts +12 -0
- package/dist/types/src/extensions/command/menu.d.ts.map +1 -0
- package/dist/types/src/extensions/command/preview.d.ts +12 -0
- package/dist/types/src/extensions/command/preview.d.ts.map +1 -0
- package/dist/types/src/extensions/command/state.d.ts.map +1 -1
- package/dist/types/src/extensions/comments.d.ts +3 -3
- package/dist/types/src/extensions/comments.d.ts.map +1 -1
- package/dist/types/src/extensions/factories.d.ts +1 -1
- package/dist/types/src/extensions/factories.d.ts.map +1 -1
- package/dist/types/src/{styles/stack-item-content-class-names.d.ts → fragments.d.ts} +1 -1
- package/dist/types/src/fragments.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +0 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/styles/theme.d.ts.map +1 -1
- package/package.json +27 -27
- package/src/InputMode.stories.tsx +4 -4
- package/src/TextEditor.stories.tsx +173 -59
- package/src/components/EditorToolbar/EditorToolbar.tsx +4 -5
- package/src/defaults.ts +12 -0
- package/src/extensions/command/command.ts +21 -2
- package/src/extensions/command/hint.ts +3 -0
- package/src/extensions/command/menu.ts +100 -0
- package/src/extensions/command/preview.ts +79 -0
- package/src/extensions/command/state.ts +9 -4
- package/src/extensions/comments.ts +6 -10
- package/src/extensions/factories.ts +3 -3
- package/src/{styles/stack-item-content-class-names.ts → fragments.ts} +3 -1
- package/src/index.ts +0 -4
- package/src/styles/theme.ts +5 -1
- package/dist/types/src/styles/stack-item-content-class-names.d.ts.map +0 -1
@@ -6,6 +6,8 @@ import { type Extension } from '@codemirror/state';
|
|
6
6
|
import { EditorView, keymap } from '@codemirror/view';
|
7
7
|
|
8
8
|
import { hintViewPlugin } from './hint';
|
9
|
+
import { floatingMenu } from './menu';
|
10
|
+
import { preview, type PreviewOptions } from './preview';
|
9
11
|
import { closeEffect, commandConfig, commandKeyBindings, commandState } from './state';
|
10
12
|
|
11
13
|
// TODO(burdon): Create knowledge base for CM notes and ideas.
|
@@ -13,23 +15,40 @@ import { closeEffect, commandConfig, commandKeyBindings, commandState } from './
|
|
13
15
|
// https://github.com/saminzadeh/codemirror-extension-inline-suggestion
|
14
16
|
// https://github.com/ChromeDevTools/devtools-frontend/blob/main/front_end/ui/components/text_editor/config.ts#L370
|
15
17
|
|
18
|
+
// TODO(burdon): Discriminated union.
|
16
19
|
export type CommandAction = {
|
17
20
|
insert?: string;
|
18
21
|
};
|
19
22
|
|
20
23
|
export type CommandOptions = {
|
21
|
-
onRender: (el: HTMLElement, cb: (action?: CommandAction) => void) => void;
|
22
24
|
onHint: () => string | undefined;
|
23
|
-
|
25
|
+
onRenderDialog: (el: HTMLElement, cb: (action?: CommandAction) => void) => void;
|
26
|
+
onRenderMenu: (el: HTMLElement, cb: () => void) => void;
|
27
|
+
} & Pick<PreviewOptions, 'onRenderPreview'>;
|
24
28
|
|
25
29
|
export const command = (options: CommandOptions): Extension => {
|
26
30
|
return [
|
27
31
|
commandConfig.of(options),
|
28
32
|
commandState,
|
29
33
|
keymap.of(commandKeyBindings),
|
34
|
+
preview(options),
|
35
|
+
floatingMenu(options),
|
30
36
|
hintViewPlugin(options),
|
31
37
|
EditorView.focusChangeEffect.of((_, focusing) => {
|
32
38
|
return focusing ? closeEffect.of(null) : null;
|
33
39
|
}),
|
40
|
+
EditorView.theme({
|
41
|
+
'.cm-tooltip': {
|
42
|
+
background: 'transparent',
|
43
|
+
},
|
44
|
+
'.cm-preview': {
|
45
|
+
marginLeft: '-1rem',
|
46
|
+
marginRight: '-1rem',
|
47
|
+
padding: '1rem',
|
48
|
+
borderRadius: '1rem',
|
49
|
+
background: 'var(--dx-modalSurface)',
|
50
|
+
border: '1px solid var(--dx-separator)',
|
51
|
+
},
|
52
|
+
}),
|
34
53
|
];
|
35
54
|
};
|
@@ -24,6 +24,7 @@ class CommandHint extends WidgetType {
|
|
24
24
|
} else {
|
25
25
|
wrap.setAttribute('aria-hidden', 'true');
|
26
26
|
}
|
27
|
+
|
27
28
|
return wrap;
|
28
29
|
}
|
29
30
|
|
@@ -32,12 +33,14 @@ class CommandHint extends WidgetType {
|
|
32
33
|
if (!rects.length) {
|
33
34
|
return null;
|
34
35
|
}
|
36
|
+
|
35
37
|
const style = window.getComputedStyle(dom.parentNode as HTMLElement);
|
36
38
|
const rect = flattenRect(rects[0], style.direction !== 'rtl');
|
37
39
|
const lineHeight = parseInt(style.lineHeight);
|
38
40
|
if (rect.bottom - rect.top > lineHeight * 1.5) {
|
39
41
|
return { left: rect.left, right: rect.right, top: rect.top, bottom: rect.top + lineHeight };
|
40
42
|
}
|
43
|
+
|
41
44
|
return rect;
|
42
45
|
}
|
43
46
|
|
@@ -0,0 +1,100 @@
|
|
1
|
+
//
|
2
|
+
// Copyright 2024 DXOS.org
|
3
|
+
//
|
4
|
+
|
5
|
+
import { type BlockInfo, type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
|
6
|
+
|
7
|
+
import { type CommandOptions } from './command';
|
8
|
+
import { closeEffect, openCommand, openEffect } from './state';
|
9
|
+
|
10
|
+
// TODO(burdon): Trigger completion on click.
|
11
|
+
// TODO(burdon): Hide when dialog is open.
|
12
|
+
export const floatingMenu = (options: CommandOptions) =>
|
13
|
+
ViewPlugin.fromClass(
|
14
|
+
class {
|
15
|
+
button: HTMLElement;
|
16
|
+
view: EditorView;
|
17
|
+
rafId: number | null = null;
|
18
|
+
|
19
|
+
constructor(view: EditorView) {
|
20
|
+
this.view = view;
|
21
|
+
|
22
|
+
// Position context: scrollDOM
|
23
|
+
const container = view.scrollDOM;
|
24
|
+
if (getComputedStyle(container).position === 'static') {
|
25
|
+
container.style.position = 'relative';
|
26
|
+
}
|
27
|
+
|
28
|
+
// Render menu externally.
|
29
|
+
this.button = document.createElement('div');
|
30
|
+
this.button.style.position = 'absolute';
|
31
|
+
this.button.style.zIndex = '10';
|
32
|
+
this.button.style.display = 'none';
|
33
|
+
|
34
|
+
options.onRenderMenu(this.button, () => {
|
35
|
+
openCommand(view);
|
36
|
+
});
|
37
|
+
container.appendChild(this.button);
|
38
|
+
|
39
|
+
// Listen for scroll events.
|
40
|
+
container.addEventListener('scroll', this.scheduleUpdate);
|
41
|
+
this.scheduleUpdate();
|
42
|
+
}
|
43
|
+
|
44
|
+
update(update: ViewUpdate) {
|
45
|
+
// TODO(burdon): Timer to fade in/out.
|
46
|
+
if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(openEffect)))) {
|
47
|
+
this.button.style.display = 'none';
|
48
|
+
} else if (update.transactions.some((tr) => tr.effects.some((effect) => effect.is(closeEffect)))) {
|
49
|
+
this.button.style.display = 'block';
|
50
|
+
} else if (update.selectionSet || update.viewportChanged || update.docChanged || update.geometryChanged) {
|
51
|
+
this.scheduleUpdate();
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
55
|
+
scheduleUpdate() {
|
56
|
+
if (this.rafId != null) {
|
57
|
+
cancelAnimationFrame(this.rafId);
|
58
|
+
}
|
59
|
+
this.rafId = requestAnimationFrame(() => this.updateButtonPosition());
|
60
|
+
}
|
61
|
+
|
62
|
+
updateButtonPosition() {
|
63
|
+
const pos = this.view.state.selection.main.head;
|
64
|
+
const lineBlock: BlockInfo = this.view.lineBlockAt(pos);
|
65
|
+
const domInfo = this.view.domAtPos(lineBlock.from);
|
66
|
+
|
67
|
+
// Find nearest HTMLElement for the line block
|
68
|
+
let node: Node | null = domInfo.node;
|
69
|
+
while (node && !(node instanceof HTMLElement)) {
|
70
|
+
node = node.parentNode;
|
71
|
+
}
|
72
|
+
|
73
|
+
if (!node) {
|
74
|
+
this.button.style.display = 'none';
|
75
|
+
return;
|
76
|
+
}
|
77
|
+
|
78
|
+
const lineRect = (node as HTMLElement).getBoundingClientRect();
|
79
|
+
const containerRect = this.view.scrollDOM.getBoundingClientRect();
|
80
|
+
|
81
|
+
// Account for scroll and padding/margin in scrollDOM.
|
82
|
+
const offsetTop = lineRect.top - containerRect.top + this.view.scrollDOM.scrollTop;
|
83
|
+
const offsetLeft = this.view.scrollDOM.clientWidth + this.view.scrollDOM.scrollLeft - lineRect.x;
|
84
|
+
|
85
|
+
// TODO(burdon): Position is incorrect if cursor is in fenced code block.
|
86
|
+
// console.log('offsetTop', lineRect, containerRect);
|
87
|
+
|
88
|
+
this.button.style.top = `${offsetTop}px`;
|
89
|
+
this.button.style.left = `${offsetLeft}px`;
|
90
|
+
this.button.style.display = 'block';
|
91
|
+
}
|
92
|
+
|
93
|
+
destroy() {
|
94
|
+
this.button.remove();
|
95
|
+
if (this.rafId != null) {
|
96
|
+
cancelAnimationFrame(this.rafId);
|
97
|
+
}
|
98
|
+
}
|
99
|
+
},
|
100
|
+
);
|
@@ -0,0 +1,79 @@
|
|
1
|
+
//
|
2
|
+
// Copyright 2023 DXOS.org
|
3
|
+
//
|
4
|
+
|
5
|
+
import { syntaxTree } from '@codemirror/language';
|
6
|
+
import {
|
7
|
+
type EditorState,
|
8
|
+
type Extension,
|
9
|
+
type RangeSet,
|
10
|
+
RangeSetBuilder,
|
11
|
+
StateField,
|
12
|
+
type Transaction,
|
13
|
+
} from '@codemirror/state';
|
14
|
+
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
15
|
+
|
16
|
+
export type PreviewOptions = {
|
17
|
+
onRenderPreview: (el: HTMLElement, props: { url: string; text: string }) => void;
|
18
|
+
};
|
19
|
+
|
20
|
+
/**
|
21
|
+
* Create image decorations.
|
22
|
+
*/
|
23
|
+
export const preview = (options: PreviewOptions): Extension => {
|
24
|
+
return [
|
25
|
+
StateField.define<DecorationSet>({
|
26
|
+
create: (state) => buildDecorations(state, options),
|
27
|
+
update: (_: RangeSet<Decoration>, tr: Transaction) => buildDecorations(tr.state, options),
|
28
|
+
// TODO(burdon): Make atomic.
|
29
|
+
provide: (field) => EditorView.decorations.from(field),
|
30
|
+
}),
|
31
|
+
];
|
32
|
+
};
|
33
|
+
|
34
|
+
// TODO(burdon): Make atomic.
|
35
|
+
const buildDecorations = (state: EditorState, options: PreviewOptions) => {
|
36
|
+
const builder = new RangeSetBuilder<Decoration>();
|
37
|
+
syntaxTree(state).iterate({
|
38
|
+
enter: (node) => {
|
39
|
+
if (node.name === 'Link') {
|
40
|
+
const urlNode = node.node.getChild('URL');
|
41
|
+
if (urlNode) {
|
42
|
+
const text = state.sliceDoc(node.from + 1, urlNode.from - 2);
|
43
|
+
const url = state.sliceDoc(urlNode.from, urlNode.to);
|
44
|
+
builder.add(
|
45
|
+
node.from,
|
46
|
+
node.to,
|
47
|
+
Decoration.replace({
|
48
|
+
block: true, // Prevent cursor from entering.
|
49
|
+
widget: new PreviewWidget(options.onRenderPreview, url, text),
|
50
|
+
}),
|
51
|
+
);
|
52
|
+
}
|
53
|
+
}
|
54
|
+
},
|
55
|
+
});
|
56
|
+
|
57
|
+
return builder.finish();
|
58
|
+
};
|
59
|
+
|
60
|
+
class PreviewWidget extends WidgetType {
|
61
|
+
constructor(
|
62
|
+
readonly _onRenderPreview: PreviewOptions['onRenderPreview'],
|
63
|
+
readonly _url: string,
|
64
|
+
readonly _text: string,
|
65
|
+
) {
|
66
|
+
super();
|
67
|
+
}
|
68
|
+
|
69
|
+
override eq(other: this) {
|
70
|
+
return this._url === (other as any as PreviewWidget)._url;
|
71
|
+
}
|
72
|
+
|
73
|
+
override toDOM(view: EditorView) {
|
74
|
+
const root = document.createElement('div');
|
75
|
+
root.classList.add('cm-preview');
|
76
|
+
this._onRenderPreview(root, { url: this._url, text: this._text });
|
77
|
+
return root;
|
78
|
+
}
|
79
|
+
}
|
@@ -4,10 +4,10 @@
|
|
4
4
|
|
5
5
|
import { StateEffect, StateField } from '@codemirror/state';
|
6
6
|
import {
|
7
|
+
showTooltip,
|
7
8
|
type Command,
|
8
9
|
type EditorView,
|
9
10
|
type KeyBinding,
|
10
|
-
showTooltip,
|
11
11
|
type Tooltip,
|
12
12
|
type TooltipView,
|
13
13
|
} from '@codemirror/view';
|
@@ -50,14 +50,17 @@ export const commandState = StateField.define<CommandState>({
|
|
50
50
|
}
|
51
51
|
|
52
52
|
// Render react component.
|
53
|
-
options.
|
53
|
+
options.onRenderDialog(dom, (action) => {
|
54
54
|
view.dispatch({ effects: closeEffect.of(null) });
|
55
55
|
if (action?.insert?.length) {
|
56
|
+
// Insert into editor.
|
57
|
+
const text = action.insert + '\n';
|
56
58
|
view.dispatch({
|
57
|
-
changes: { from: pos, insert:
|
58
|
-
selection: { anchor: pos +
|
59
|
+
changes: { from: pos, insert: text },
|
60
|
+
selection: { anchor: pos + text.length },
|
59
61
|
});
|
60
62
|
}
|
63
|
+
|
61
64
|
// NOTE: Truncates text if set focus immediately.
|
62
65
|
requestAnimationFrame(() => view.focus());
|
63
66
|
});
|
@@ -88,6 +91,7 @@ export const openCommand: Command = (view: EditorView) => {
|
|
88
91
|
return true;
|
89
92
|
}
|
90
93
|
}
|
94
|
+
|
91
95
|
return false;
|
92
96
|
};
|
93
97
|
|
@@ -96,6 +100,7 @@ export const closeCommand: Command = (view: EditorView) => {
|
|
96
100
|
view.dispatch({ effects: closeEffect.of(null) });
|
97
101
|
return true;
|
98
102
|
}
|
103
|
+
|
99
104
|
return false;
|
100
105
|
};
|
101
106
|
|
@@ -4,12 +4,12 @@
|
|
4
4
|
|
5
5
|
import { invertedEffects } from '@codemirror/commands';
|
6
6
|
import {
|
7
|
+
type ChangeDesc,
|
8
|
+
type EditorState,
|
7
9
|
type Extension,
|
8
10
|
StateEffect,
|
9
11
|
StateField,
|
10
12
|
type Text,
|
11
|
-
type ChangeDesc,
|
12
|
-
type EditorState,
|
13
13
|
} from '@codemirror/state';
|
14
14
|
import {
|
15
15
|
hoverTooltip,
|
@@ -24,7 +24,7 @@ import {
|
|
24
24
|
import sortBy from 'lodash.sortby';
|
25
25
|
import { useEffect, useMemo } from 'react';
|
26
26
|
|
27
|
-
import { debounce, type
|
27
|
+
import { debounce, type CleanupFn } from '@dxos/async';
|
28
28
|
import { type ReactiveObject } from '@dxos/live-object';
|
29
29
|
import { log } from '@dxos/log';
|
30
30
|
import { isNonNullable } from '@dxos/util';
|
@@ -181,6 +181,7 @@ const handleCommentClick = EditorView.domEventHandlers({
|
|
181
181
|
return false;
|
182
182
|
},
|
183
183
|
});
|
184
|
+
|
184
185
|
//
|
185
186
|
// Cut-and-paste.
|
186
187
|
//
|
@@ -575,12 +576,7 @@ const hasActiveSelection = (state: EditorState): boolean => {
|
|
575
576
|
class ExternalCommentSync implements PluginValue {
|
576
577
|
private readonly unsubscribe: () => void;
|
577
578
|
|
578
|
-
constructor(
|
579
|
-
view: EditorView,
|
580
|
-
id: string,
|
581
|
-
subscribe: (sink: () => void) => UnsubscribeCallback,
|
582
|
-
getComments: () => Comment[],
|
583
|
-
) {
|
579
|
+
constructor(view: EditorView, id: string, subscribe: (sink: () => void) => CleanupFn, getComments: () => Comment[]) {
|
584
580
|
const updateComments = () => {
|
585
581
|
const comments = getComments();
|
586
582
|
if (id === view.state.facet(documentId)) {
|
@@ -599,7 +595,7 @@ class ExternalCommentSync implements PluginValue {
|
|
599
595
|
// TODO(burdon): Needs comment.
|
600
596
|
export const createExternalCommentSync = (
|
601
597
|
id: string,
|
602
|
-
subscribe: (sink: () => void) =>
|
598
|
+
subscribe: (sink: () => void) => CleanupFn,
|
603
599
|
getComments: () => Comment[],
|
604
600
|
): Extension =>
|
605
601
|
ViewPlugin.fromClass(
|
@@ -62,7 +62,7 @@ export type BasicExtensionsOptions = {
|
|
62
62
|
lineWrapping?: boolean;
|
63
63
|
placeholder?: string;
|
64
64
|
/** If true user cannot edit the text, but they can still select and copy it. */
|
65
|
-
|
65
|
+
readOnly?: boolean;
|
66
66
|
search?: boolean;
|
67
67
|
scrollPastEnd?: boolean;
|
68
68
|
standardKeymap?: boolean;
|
@@ -74,7 +74,6 @@ const defaultBasicOptions: BasicExtensionsOptions = {
|
|
74
74
|
bracketMatching: true,
|
75
75
|
closeBrackets: true,
|
76
76
|
drawSelection: true,
|
77
|
-
editable: true,
|
78
77
|
focus: true,
|
79
78
|
history: true,
|
80
79
|
keymap: 'standard',
|
@@ -102,13 +101,14 @@ export const createBasicExtensions = (_props?: BasicExtensionsOptions): Extensio
|
|
102
101
|
props.closeBrackets && closeBrackets(),
|
103
102
|
props.dropCursor && dropCursor(),
|
104
103
|
props.drawSelection && drawSelection({ cursorBlinkRate: 1_200 }),
|
104
|
+
props.editable !== undefined && EditorView.editable.of(props.editable),
|
105
105
|
props.focus && focus,
|
106
106
|
props.highlightActiveLine && highlightActiveLine(),
|
107
107
|
props.history && history(),
|
108
108
|
props.lineNumbers && lineNumbers(),
|
109
109
|
props.lineWrapping && EditorView.lineWrapping,
|
110
110
|
props.placeholder && placeholder(props.placeholder),
|
111
|
-
props.
|
111
|
+
props.readOnly !== undefined && EditorState.readOnly.of(props.readOnly),
|
112
112
|
props.scrollPastEnd && scrollPastEnd(),
|
113
113
|
props.tabSize && EditorState.tabSize.of(props.tabSize),
|
114
114
|
|
@@ -4,9 +4,11 @@
|
|
4
4
|
|
5
5
|
import { mx } from '@dxos/react-ui-theme';
|
6
6
|
|
7
|
+
// TODO(burdon): Move this to a common plugin.
|
8
|
+
|
7
9
|
export const stackItemContentEditorClassNames = (role?: string) =>
|
8
10
|
mx(
|
9
|
-
'dx-focus-ring-inset data-[toolbar=disabled]:pbs-2
|
11
|
+
'attention-surface dx-focus-ring-inset data-[toolbar=disabled]:pbs-2',
|
10
12
|
role === 'section' ? '[&_.cm-scroller]:overflow-hidden [&_.cm-scroller]:min-bs-24' : 'min-bs-0',
|
11
13
|
);
|
12
14
|
|
package/src/index.ts
CHANGED
@@ -14,10 +14,6 @@ export * from './components';
|
|
14
14
|
export * from './defaults';
|
15
15
|
export * from './extensions';
|
16
16
|
export * from './hooks';
|
17
|
-
export {
|
18
|
-
stackItemContentEditorClassNames,
|
19
|
-
stackItemContentToolbarClassNames,
|
20
|
-
} from './styles/stack-item-content-class-names';
|
21
17
|
export * from './types';
|
22
18
|
export * from './util';
|
23
19
|
|
package/src/styles/theme.ts
CHANGED
@@ -77,6 +77,10 @@ export const defaultTheme: ThemeStyles = {
|
|
77
77
|
background: 'transparent',
|
78
78
|
},
|
79
79
|
'.cm-gutter': {},
|
80
|
+
'.cm-gutter.cm-lineNumbers': {
|
81
|
+
paddingRight: '4px',
|
82
|
+
borderRight: '1px solid var(--dx-separator)',
|
83
|
+
},
|
80
84
|
'.cm-gutter.cm-lineNumbers .cm-gutterElement': {
|
81
85
|
minWidth: '40px',
|
82
86
|
alignContent: 'center',
|
@@ -86,7 +90,7 @@ export const defaultTheme: ThemeStyles = {
|
|
86
90
|
*/
|
87
91
|
'.cm-gutterElement': {
|
88
92
|
alignItems: 'center',
|
89
|
-
fontSize: '
|
93
|
+
fontSize: '12px',
|
90
94
|
},
|
91
95
|
|
92
96
|
/**
|
@@ -1 +0,0 @@
|
|
1
|
-
{"version":3,"file":"stack-item-content-class-names.d.ts","sourceRoot":"","sources":["../../../../src/styles/stack-item-content-class-names.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,gCAAgC,UAAW,MAAM,WAI3D,CAAC;AAEJ,eAAO,MAAM,iCAAiC,UAAW,MAAM,WAI5D,CAAC"}
|