@dxos/plugin-sheet 0.6.8-main.046e6cf

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 (147) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +14 -0
  3. package/dist/lib/browser/SheetContainer-H22IDJ43.mjs +3740 -0
  4. package/dist/lib/browser/SheetContainer-H22IDJ43.mjs.map +7 -0
  5. package/dist/lib/browser/chunk-6VPEAUG6.mjs +82 -0
  6. package/dist/lib/browser/chunk-6VPEAUG6.mjs.map +7 -0
  7. package/dist/lib/browser/chunk-AT2FJXQX.mjs +861 -0
  8. package/dist/lib/browser/chunk-AT2FJXQX.mjs.map +7 -0
  9. package/dist/lib/browser/chunk-JRL5LGCE.mjs +18 -0
  10. package/dist/lib/browser/chunk-JRL5LGCE.mjs.map +7 -0
  11. package/dist/lib/browser/index.mjs +213 -0
  12. package/dist/lib/browser/index.mjs.map +7 -0
  13. package/dist/lib/browser/meta.json +1 -0
  14. package/dist/lib/browser/meta.mjs +9 -0
  15. package/dist/lib/browser/meta.mjs.map +7 -0
  16. package/dist/lib/browser/types.mjs +22 -0
  17. package/dist/lib/browser/types.mjs.map +7 -0
  18. package/dist/lib/node/SheetContainer-S32KTNZ6.cjs +3731 -0
  19. package/dist/lib/node/SheetContainer-S32KTNZ6.cjs.map +7 -0
  20. package/dist/lib/node/chunk-4CE6FK5Z.cjs +108 -0
  21. package/dist/lib/node/chunk-4CE6FK5Z.cjs.map +7 -0
  22. package/dist/lib/node/chunk-BJ6ZD7MN.cjs +51 -0
  23. package/dist/lib/node/chunk-BJ6ZD7MN.cjs.map +7 -0
  24. package/dist/lib/node/chunk-FCKJ4QRM.cjs +881 -0
  25. package/dist/lib/node/chunk-FCKJ4QRM.cjs.map +7 -0
  26. package/dist/lib/node/index.cjs +226 -0
  27. package/dist/lib/node/index.cjs.map +7 -0
  28. package/dist/lib/node/meta.cjs +30 -0
  29. package/dist/lib/node/meta.cjs.map +7 -0
  30. package/dist/lib/node/meta.json +1 -0
  31. package/dist/lib/node/types.cjs +44 -0
  32. package/dist/lib/node/types.cjs.map +7 -0
  33. package/dist/types/src/SheetPlugin.d.ts +4 -0
  34. package/dist/types/src/SheetPlugin.d.ts.map +1 -0
  35. package/dist/types/src/components/CellEditor/CellEditor.d.ts +14 -0
  36. package/dist/types/src/components/CellEditor/CellEditor.d.ts.map +1 -0
  37. package/dist/types/src/components/CellEditor/CellEditor.stories.d.ts +29 -0
  38. package/dist/types/src/components/CellEditor/CellEditor.stories.d.ts.map +1 -0
  39. package/dist/types/src/components/CellEditor/extension.d.ts +18 -0
  40. package/dist/types/src/components/CellEditor/extension.d.ts.map +1 -0
  41. package/dist/types/src/components/CellEditor/extension.test.d.ts +2 -0
  42. package/dist/types/src/components/CellEditor/extension.test.d.ts.map +1 -0
  43. package/dist/types/src/components/CellEditor/functions.d.ts +66 -0
  44. package/dist/types/src/components/CellEditor/functions.d.ts.map +1 -0
  45. package/dist/types/src/components/CellEditor/index.d.ts +3 -0
  46. package/dist/types/src/components/CellEditor/index.d.ts.map +1 -0
  47. package/dist/types/src/components/ComputeGraph/async-function.d.ts +52 -0
  48. package/dist/types/src/components/ComputeGraph/async-function.d.ts.map +1 -0
  49. package/dist/types/src/components/ComputeGraph/custom.d.ts +21 -0
  50. package/dist/types/src/components/ComputeGraph/custom.d.ts.map +1 -0
  51. package/dist/types/src/components/ComputeGraph/edge-function.d.ts +20 -0
  52. package/dist/types/src/components/ComputeGraph/edge-function.d.ts.map +1 -0
  53. package/dist/types/src/components/ComputeGraph/graph-context.d.ts +11 -0
  54. package/dist/types/src/components/ComputeGraph/graph-context.d.ts.map +1 -0
  55. package/dist/types/src/components/ComputeGraph/graph.browser.test.d.ts +2 -0
  56. package/dist/types/src/components/ComputeGraph/graph.browser.test.d.ts.map +1 -0
  57. package/dist/types/src/components/ComputeGraph/graph.d.ts +21 -0
  58. package/dist/types/src/components/ComputeGraph/graph.d.ts.map +1 -0
  59. package/dist/types/src/components/ComputeGraph/index.d.ts +4 -0
  60. package/dist/types/src/components/ComputeGraph/index.d.ts.map +1 -0
  61. package/dist/types/src/components/Sheet/Sheet.d.ts +55 -0
  62. package/dist/types/src/components/Sheet/Sheet.d.ts.map +1 -0
  63. package/dist/types/src/components/Sheet/Sheet.stories.d.ts +54 -0
  64. package/dist/types/src/components/Sheet/Sheet.stories.d.ts.map +1 -0
  65. package/dist/types/src/components/Sheet/formatting.d.ts +14 -0
  66. package/dist/types/src/components/Sheet/formatting.d.ts.map +1 -0
  67. package/dist/types/src/components/Sheet/grid.d.ts +52 -0
  68. package/dist/types/src/components/Sheet/grid.d.ts.map +1 -0
  69. package/dist/types/src/components/Sheet/index.d.ts +2 -0
  70. package/dist/types/src/components/Sheet/index.d.ts.map +1 -0
  71. package/dist/types/src/components/Sheet/nav.d.ts +29 -0
  72. package/dist/types/src/components/Sheet/nav.d.ts.map +1 -0
  73. package/dist/types/src/components/Sheet/sheet-context.d.ts +24 -0
  74. package/dist/types/src/components/Sheet/sheet-context.d.ts.map +1 -0
  75. package/dist/types/src/components/Sheet/util.d.ts +18 -0
  76. package/dist/types/src/components/Sheet/util.d.ts.map +1 -0
  77. package/dist/types/src/components/SheetContainer.d.ts +9 -0
  78. package/dist/types/src/components/SheetContainer.d.ts.map +1 -0
  79. package/dist/types/src/components/Toolbar/Toolbar.d.ts +21 -0
  80. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -0
  81. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +35 -0
  82. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -0
  83. package/dist/types/src/components/Toolbar/common.d.ts +20 -0
  84. package/dist/types/src/components/Toolbar/common.d.ts.map +1 -0
  85. package/dist/types/src/components/Toolbar/index.d.ts +2 -0
  86. package/dist/types/src/components/Toolbar/index.d.ts.map +1 -0
  87. package/dist/types/src/components/index.d.ts +7 -0
  88. package/dist/types/src/components/index.d.ts.map +1 -0
  89. package/dist/types/src/index.d.ts +4 -0
  90. package/dist/types/src/index.d.ts.map +1 -0
  91. package/dist/types/src/meta.d.ts +15 -0
  92. package/dist/types/src/meta.d.ts.map +1 -0
  93. package/dist/types/src/model/index.d.ts +3 -0
  94. package/dist/types/src/model/index.d.ts.map +1 -0
  95. package/dist/types/src/model/model.browser.test.d.ts +2 -0
  96. package/dist/types/src/model/model.browser.test.d.ts.map +1 -0
  97. package/dist/types/src/model/model.d.ts +142 -0
  98. package/dist/types/src/model/model.d.ts.map +1 -0
  99. package/dist/types/src/model/types.d.ts +17 -0
  100. package/dist/types/src/model/types.d.ts.map +1 -0
  101. package/dist/types/src/model/types.test.d.ts +2 -0
  102. package/dist/types/src/model/types.test.d.ts.map +1 -0
  103. package/dist/types/src/model/util.d.ts +15 -0
  104. package/dist/types/src/model/util.d.ts.map +1 -0
  105. package/dist/types/src/translations.d.ts +16 -0
  106. package/dist/types/src/translations.d.ts.map +1 -0
  107. package/dist/types/src/types.d.ts +94 -0
  108. package/dist/types/src/types.d.ts.map +1 -0
  109. package/package.json +122 -0
  110. package/src/SheetPlugin.tsx +150 -0
  111. package/src/components/CellEditor/CellEditor.stories.tsx +88 -0
  112. package/src/components/CellEditor/CellEditor.tsx +113 -0
  113. package/src/components/CellEditor/extension.test.ts +42 -0
  114. package/src/components/CellEditor/extension.ts +286 -0
  115. package/src/components/CellEditor/functions.ts +2017 -0
  116. package/src/components/CellEditor/index.ts +6 -0
  117. package/src/components/ComputeGraph/async-function.ts +148 -0
  118. package/src/components/ComputeGraph/custom.ts +70 -0
  119. package/src/components/ComputeGraph/edge-function.ts +60 -0
  120. package/src/components/ComputeGraph/graph-context.tsx +37 -0
  121. package/src/components/ComputeGraph/graph.browser.test.ts +49 -0
  122. package/src/components/ComputeGraph/graph.ts +52 -0
  123. package/src/components/ComputeGraph/index.ts +7 -0
  124. package/src/components/Sheet/Sheet.stories.tsx +329 -0
  125. package/src/components/Sheet/Sheet.tsx +1164 -0
  126. package/src/components/Sheet/formatting.ts +106 -0
  127. package/src/components/Sheet/grid.ts +191 -0
  128. package/src/components/Sheet/index.ts +5 -0
  129. package/src/components/Sheet/nav.ts +157 -0
  130. package/src/components/Sheet/sheet-context.tsx +101 -0
  131. package/src/components/Sheet/util.ts +56 -0
  132. package/src/components/SheetContainer.tsx +30 -0
  133. package/src/components/Toolbar/Toolbar.stories.tsx +36 -0
  134. package/src/components/Toolbar/Toolbar.tsx +198 -0
  135. package/src/components/Toolbar/common.tsx +72 -0
  136. package/src/components/Toolbar/index.ts +5 -0
  137. package/src/components/index.ts +10 -0
  138. package/src/index.ts +9 -0
  139. package/src/meta.tsx +18 -0
  140. package/src/model/index.ts +6 -0
  141. package/src/model/model.browser.test.ts +100 -0
  142. package/src/model/model.ts +480 -0
  143. package/src/model/types.test.ts +92 -0
  144. package/src/model/types.ts +71 -0
  145. package/src/model/util.ts +36 -0
  146. package/src/translations.ts +22 -0
  147. package/src/types.ts +110 -0
@@ -0,0 +1,150 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type IconProps, GridNine } from '@phosphor-icons/react';
6
+ import React from 'react';
7
+
8
+ import {
9
+ NavigationAction,
10
+ parseIntentPlugin,
11
+ resolvePlugin,
12
+ type PluginDefinition,
13
+ type LayoutCoordinate,
14
+ } from '@dxos/app-framework';
15
+ import { create } from '@dxos/echo-schema';
16
+ import { parseClientPlugin } from '@dxos/plugin-client';
17
+ import { type ActionGroup, createExtension, isActionGroup } from '@dxos/plugin-graph';
18
+ import { SpaceAction } from '@dxos/plugin-space';
19
+ import { getSpace, isEchoObject } from '@dxos/react-client/echo';
20
+
21
+ import { ComputeGraphContextProvider, createComputeGraph, SheetContainer, type ComputeGraph } from './components';
22
+ import meta, { SHEET_PLUGIN } from './meta';
23
+ import { SheetModel } from './model';
24
+ import translations from './translations';
25
+ import { createSheet, SheetAction, type SheetPluginProvides, SheetType } from './types';
26
+
27
+ export const SheetPlugin = (): PluginDefinition<SheetPluginProvides> => {
28
+ const graphs = create<Record<string, ComputeGraph>>({});
29
+ const setGraph = (key: string, graph: ComputeGraph) => {
30
+ graphs[key] = graph;
31
+ };
32
+
33
+ return {
34
+ meta,
35
+ provides: {
36
+ context: ({ children }) => {
37
+ return (
38
+ <ComputeGraphContextProvider graphs={graphs} setGraph={setGraph}>
39
+ {children}
40
+ </ComputeGraphContextProvider>
41
+ );
42
+ },
43
+ metadata: {
44
+ records: {
45
+ [SheetType.typename]: {
46
+ placeholder: ['sheet title placeholder', { ns: SHEET_PLUGIN }],
47
+ icon: (props: IconProps) => <GridNine {...props} />,
48
+ iconSymbol: 'ph--grid-nine--regular',
49
+ },
50
+ },
51
+ },
52
+ translations,
53
+ echo: {
54
+ schema: [SheetType],
55
+ },
56
+ graph: {
57
+ builder: (plugins) => {
58
+ const client = resolvePlugin(plugins, parseClientPlugin)?.provides.client;
59
+ const dispatch = resolvePlugin(plugins, parseIntentPlugin)?.provides.intent.dispatch;
60
+ if (!client || !dispatch) {
61
+ return [];
62
+ }
63
+
64
+ return createExtension({
65
+ id: SheetAction.CREATE,
66
+ filter: (node): node is ActionGroup => isActionGroup(node) && node.id.startsWith(SpaceAction.ADD_OBJECT),
67
+ actions: ({ node }) => {
68
+ const id = node.id.split('/').at(-1);
69
+ const [spaceId, objectId] = id?.split(':') ?? [];
70
+ const space = client.spaces.get().find((space) => space.id === spaceId);
71
+ const object = objectId && space?.db.getObjectById(objectId);
72
+ const target = objectId ? object : space;
73
+ if (!target) {
74
+ return;
75
+ }
76
+
77
+ return [
78
+ {
79
+ id: `${SHEET_PLUGIN}/create/${node.id}`,
80
+ data: async () => {
81
+ await dispatch([
82
+ { plugin: SHEET_PLUGIN, action: SheetAction.CREATE, data: { space } },
83
+ { action: SpaceAction.ADD_OBJECT, data: { target } },
84
+ { action: NavigationAction.OPEN },
85
+ ]);
86
+ },
87
+ properties: {
88
+ label: ['create sheet label', { ns: SHEET_PLUGIN }],
89
+ icon: (props: IconProps) => <GridNine {...props} />,
90
+ iconSymbol: 'ph--grid-nine--regular',
91
+ testId: 'sheetPlugin.createObject',
92
+ },
93
+ },
94
+ ];
95
+ },
96
+ });
97
+ },
98
+ },
99
+ stack: {
100
+ creators: [
101
+ {
102
+ id: 'create-stack-section-sheet',
103
+ testId: 'sheetPlugin.createSectionSpaceSheet',
104
+ type: ['plugin name', { ns: SHEET_PLUGIN }],
105
+ label: ['create sheet section label', { ns: SHEET_PLUGIN }],
106
+ icon: (props: any) => <GridNine {...props} />,
107
+ intent: [
108
+ {
109
+ plugin: SHEET_PLUGIN,
110
+ action: SheetAction.CREATE,
111
+ },
112
+ ],
113
+ },
114
+ ],
115
+ },
116
+ surface: {
117
+ component: ({ data, role = 'never' }) => {
118
+ if (!['article', 'section'].includes(role) || !isEchoObject(data.object)) {
119
+ return null;
120
+ }
121
+
122
+ const space = getSpace(data.object);
123
+ return space && data.object instanceof SheetType ? (
124
+ <SheetContainer
125
+ sheet={data.object}
126
+ space={space}
127
+ role={role}
128
+ coordinate={data.coordinate as LayoutCoordinate}
129
+ />
130
+ ) : null;
131
+ },
132
+ },
133
+ intent: {
134
+ resolver: async (intent) => {
135
+ switch (intent.action) {
136
+ case SheetAction.CREATE: {
137
+ const space = intent.data?.space;
138
+ const sheet = createSheet();
139
+ const graph = graphs[space.id] ?? createComputeGraph(space);
140
+ const model = new SheetModel(graph, sheet);
141
+ await model.initialize();
142
+ await model.destroy();
143
+ return { data: sheet };
144
+ }
145
+ }
146
+ },
147
+ },
148
+ },
149
+ };
150
+ };
@@ -0,0 +1,88 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import '@dxosTheme';
6
+
7
+ import { HyperFormula } from 'hyperformula';
8
+ import React, { useEffect, useMemo, useState } from 'react';
9
+
10
+ import { Client } from '@dxos/client';
11
+ import { createDocAccessor, type EchoReactiveObject } from '@dxos/client/echo';
12
+ import { automerge } from '@dxos/react-ui-editor';
13
+ import { withTheme } from '@dxos/storybook-utils';
14
+
15
+ import { CellEditor, type CellEditorProps } from './CellEditor';
16
+ import { sheetExtension } from './extension';
17
+ import { createSheet, SheetType } from '../../types';
18
+
19
+ export default {
20
+ title: 'plugin-sheet/CellEditor',
21
+ component: CellEditor,
22
+ render: (args: StoryProps) => <Story {...args} />,
23
+ decorators: [withTheme],
24
+ };
25
+
26
+ type StoryProps = CellEditorProps;
27
+
28
+ const Story = ({ value, ...props }: StoryProps) => {
29
+ const extension = useMemo(() => {
30
+ const functions = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }).getRegisteredFunctionNames();
31
+ return [sheetExtension({ functions })];
32
+ }, []);
33
+
34
+ return <CellEditor {...props} value={value} extension={extension} />;
35
+ };
36
+
37
+ const AutomergeStory = ({ value, ...props }: StoryProps) => {
38
+ const cell = 'A1';
39
+ const [object, setObject] = useState<EchoReactiveObject<SheetType>>();
40
+ useEffect(() => {
41
+ setTimeout(async () => {
42
+ const client = new Client();
43
+ await client.initialize();
44
+ await client.halo.createIdentity();
45
+ const space = await client.spaces.create();
46
+ client.addTypes([SheetType]);
47
+
48
+ const sheet = createSheet();
49
+ sheet.title = 'Test';
50
+ sheet.cells[cell] = { value };
51
+ space.db.add(sheet);
52
+ setObject(sheet);
53
+ });
54
+ }, [value]);
55
+
56
+ const extension = useMemo(() => {
57
+ if (!object) {
58
+ return [];
59
+ }
60
+
61
+ const functions = HyperFormula.buildEmpty({ licenseKey: 'gpl-v3' }).getRegisteredFunctionNames();
62
+ const accessor = createDocAccessor(object, ['cells', cell, 'value']);
63
+ return [automerge(accessor), sheetExtension({ functions })];
64
+ }, [object]);
65
+
66
+ return <CellEditor {...props} value={value} extension={extension} />;
67
+ };
68
+
69
+ export const Default = {};
70
+
71
+ export const AutoComplete = {
72
+ args: {
73
+ value: '=SUM',
74
+ },
75
+ };
76
+
77
+ export const Formatting = {
78
+ args: {
79
+ value: '=SUM(A1:A2, 100, TRUE, "100", SUM(A1:A2, B1:B2))',
80
+ },
81
+ };
82
+
83
+ export const Automerge = {
84
+ render: (args: StoryProps) => <AutomergeStory {...args} />,
85
+ args: {
86
+ value: '=SUM(A1:A2, 100, TRUE, "100", SUM(A1:A2, B1:B2))',
87
+ },
88
+ };
@@ -0,0 +1,113 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { type Extension } from '@codemirror/state';
6
+ import { EditorView, keymap } from '@codemirror/view';
7
+ import React, { type DOMAttributes, type KeyboardEvent } from 'react';
8
+
9
+ import { useThemeContext } from '@dxos/react-ui';
10
+ import {
11
+ type UseTextEditorProps,
12
+ createBasicExtensions,
13
+ createThemeExtensions,
14
+ preventNewline,
15
+ useTextEditor,
16
+ } from '@dxos/react-ui-editor';
17
+
18
+ export type EditorKeysProps = {
19
+ onClose: (value: string | undefined) => void;
20
+ onNav?: (value: string | undefined, ev: Pick<KeyboardEvent<HTMLInputElement>, 'key'>) => void;
21
+ };
22
+
23
+ export const editorKeys = ({ onNav, onClose }: EditorKeysProps): Extension => {
24
+ return keymap.of([
25
+ {
26
+ key: 'ArrowUp',
27
+ run: (editor) => {
28
+ const value = editor.state.doc.toString();
29
+ onNav?.(value, { key: 'ArrowUp' });
30
+ return !!onNav;
31
+ },
32
+ },
33
+ {
34
+ key: 'ArrowDown',
35
+ run: (editor) => {
36
+ const value = editor.state.doc.toString();
37
+ onNav?.(value, { key: 'ArrowDown' });
38
+ return !!onNav;
39
+ },
40
+ },
41
+ {
42
+ key: 'ArrowLeft',
43
+ run: (editor) => {
44
+ const value = editor.state.doc.toString();
45
+ onNav?.(value, { key: 'ArrowLeft' });
46
+ return !!onNav;
47
+ },
48
+ },
49
+ {
50
+ key: 'ArrowRight',
51
+ run: (editor) => {
52
+ const value = editor.state.doc.toString();
53
+ onNav?.(value, { key: 'ArrowRight' });
54
+ return !!onNav;
55
+ },
56
+ },
57
+ {
58
+ key: 'Enter',
59
+ run: (editor) => {
60
+ onClose(editor.state.doc.toString());
61
+ return true;
62
+ },
63
+ },
64
+ {
65
+ key: 'Escape',
66
+ run: () => {
67
+ onClose(undefined);
68
+ return true;
69
+ },
70
+ },
71
+ ]);
72
+ };
73
+
74
+ export type CellEditorProps = {
75
+ value?: string;
76
+ extension?: Extension;
77
+ } & Pick<UseTextEditorProps, 'autoFocus'> &
78
+ Pick<DOMAttributes<HTMLInputElement>, 'onBlur' | 'onKeyDown'>;
79
+
80
+ export const CellEditor = ({ value, extension, autoFocus, onBlur }: CellEditorProps) => {
81
+ const { themeMode } = useThemeContext();
82
+ const { parentRef } = useTextEditor(() => {
83
+ return {
84
+ autoFocus,
85
+ initialValue: value,
86
+ selection: { anchor: value?.length ?? 0 },
87
+ extensions: [
88
+ extension ?? [],
89
+ preventNewline,
90
+ EditorView.focusChangeEffect.of((_, focusing) => {
91
+ if (!focusing) {
92
+ onBlur?.({ type: 'blur' } as any);
93
+ }
94
+ return null;
95
+ }),
96
+ createBasicExtensions({ lineWrapping: false }),
97
+ createThemeExtensions({
98
+ themeMode,
99
+ slots: {
100
+ editor: {
101
+ className: 'flex w-full [&>.cm-scroller]:scrollbar-none',
102
+ },
103
+ content: {
104
+ className: '!px-2 !py-1',
105
+ },
106
+ },
107
+ }),
108
+ ],
109
+ };
110
+ }, [extension]);
111
+
112
+ return <div ref={parentRef} className='flex w-full' />;
113
+ };
@@ -0,0 +1,42 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { CompletionContext, type CompletionSource } from '@codemirror/autocomplete';
6
+ import { EditorState } from '@codemirror/state';
7
+ // @ts-ignore
8
+ import { testTree } from '@lezer/generator/test';
9
+ import { expect } from 'chai';
10
+ import { spreadsheet } from 'codemirror-lang-spreadsheet';
11
+ import { describe, test } from 'vitest';
12
+
13
+ import { sheetExtension } from './extension';
14
+
15
+ describe('formula parser', () => {
16
+ const {
17
+ language: { parser },
18
+ } = spreadsheet({});
19
+
20
+ // https://lezer-playground.vercel.app
21
+ // https://lezer.codemirror.net/docs/ref/#common.Parsing
22
+ test('parser', () => {
23
+ const result = parser.parse('SUM(A1)');
24
+ testTree(
25
+ result,
26
+ 'Program(FunctionCall(Function,Arguments(Argument(Reference(ReferenceItem(CellToken)))),CloseParen))',
27
+ );
28
+ });
29
+
30
+ test('autocomplete', async () => {
31
+ const text = '=SUM';
32
+ const state = EditorState.create({
33
+ doc: text,
34
+ selection: { anchor: 0 },
35
+ extensions: sheetExtension({ functions: ['ABS', 'SUM'] }),
36
+ });
37
+
38
+ const [f] = state.languageDataAt<CompletionSource>('autocomplete', text.length);
39
+ const result = await f(new CompletionContext(state, text.length, true));
40
+ expect(result?.options).to.have.length(1);
41
+ });
42
+ });
@@ -0,0 +1,286 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import {
6
+ type Completion,
7
+ type CompletionContext,
8
+ type CompletionResult,
9
+ acceptCompletion,
10
+ autocompletion,
11
+ completionStatus,
12
+ startCompletion,
13
+ } from '@codemirror/autocomplete';
14
+ import { HighlightStyle, type Language, syntaxHighlighting } from '@codemirror/language';
15
+ import { type Extension, Facet } from '@codemirror/state';
16
+ import { type EditorView, ViewPlugin, type ViewUpdate, keymap } from '@codemirror/view';
17
+ import { type SyntaxNode } from '@lezer/common';
18
+ import { tags } from '@lezer/highlight';
19
+ import { spreadsheet } from 'codemirror-lang-spreadsheet';
20
+
21
+ import { mx } from '@dxos/react-ui-theme';
22
+
23
+ import { functions as functionDefs } from './functions';
24
+
25
+ /**
26
+ * https://codemirror.net/examples/styling
27
+ * https://lezer.codemirror.net/docs/ref/#highlight
28
+ * https://github.com/luizzappa/codemirror-lang-spreadsheet/blob/main/src/index.ts#L28 (mapping)
29
+ */
30
+ // TODO(burdon): Define light/dark.
31
+ const highlightStyles = HighlightStyle.define([
32
+ // Function.
33
+ {
34
+ tag: tags.name,
35
+ class: 'text-primary-500',
36
+ },
37
+ // Range.
38
+ {
39
+ tag: tags.tagName,
40
+ class: 'text-pink-500',
41
+ },
42
+ // Values.
43
+ {
44
+ tag: tags.number,
45
+ class: 'text-teal-500',
46
+ },
47
+ {
48
+ tag: tags.bool,
49
+ class: 'text-teal-500',
50
+ },
51
+ {
52
+ tag: tags.string,
53
+ class: 'text-teal-500',
54
+ },
55
+ // Error.
56
+ {
57
+ tag: tags.invalid,
58
+ class: 'text-neutral-500',
59
+ },
60
+ ]);
61
+
62
+ const languageFacet = Facet.define<Language>();
63
+
64
+ export type SheetExtensionOptions = {
65
+ functions?: string[];
66
+ };
67
+
68
+ /**
69
+ * Spreadsheet formula extension and parser.
70
+ * https://github.com/luizzappa/codemirror-lang-spreadsheet
71
+ * https://github.com/luizzappa/codemirror-app-spreadsheet/blob/master/src/editor.ts
72
+ * https://github.com/codemirror/lang-example
73
+ * https://hyperformula.handsontable.com/guide/key-concepts.html#grammar
74
+ */
75
+ export const sheetExtension = ({ functions }: SheetExtensionOptions): Extension => {
76
+ const { extension, language } = spreadsheet({ idiom: 'en-US', decimalSeparator: '.' });
77
+
78
+ // Parse functions.
79
+ const functionInfo = Object.entries(functionDefs).reduce((map, [section, values]) => {
80
+ values.forEach(({ function: id, ...props }) => {
81
+ map.set(id, { function: id, ...props, section });
82
+ });
83
+ return map;
84
+ }, new Map());
85
+
86
+ const createCompletion = (name: string) => {
87
+ const { section, description, syntax } = functionInfo.get(name) ?? { section: 'Custom' };
88
+
89
+ return {
90
+ section,
91
+ label: name,
92
+ info: () => {
93
+ if (!description && !syntax) {
94
+ return null;
95
+ }
96
+
97
+ // TODO(burdon): Standardize color styles.
98
+ const root = document.createElement('div');
99
+ root.className = 'flex flex-col gap-2 text-sm';
100
+
101
+ const title = document.createElement('h2');
102
+ title.innerText = name;
103
+ title.className = 'text-lg font-mono text-primary-500';
104
+ root.appendChild(title);
105
+
106
+ if (description) {
107
+ const info = document.createElement('p');
108
+ info.innerText = description;
109
+ info.className = 'fg-subdued';
110
+ root.appendChild(info);
111
+ }
112
+
113
+ if (syntax) {
114
+ const detail = document.createElement('pre');
115
+ detail.innerText = syntax;
116
+ detail.className = 'whitespace-pre-wrap text-green-500';
117
+ root.appendChild(detail);
118
+ }
119
+
120
+ return root;
121
+ },
122
+ apply: (view, completion, from, to) => {
123
+ const insertParens = to === view.state.doc.toString().length;
124
+ view.dispatch(
125
+ view.state.update({
126
+ changes: {
127
+ from,
128
+ to,
129
+ insert: completion.label + (insertParens ? '()' : ''),
130
+ },
131
+ selection: {
132
+ anchor: from + completion.label.length + 1,
133
+ },
134
+ }),
135
+ );
136
+ },
137
+ } satisfies Completion;
138
+ };
139
+
140
+ return [
141
+ extension,
142
+ languageFacet.of(language),
143
+ language.data.of({
144
+ autocomplete: (context: CompletionContext): CompletionResult | null => {
145
+ if (context.state.doc.toString()[0] !== '=') {
146
+ return null;
147
+ }
148
+ const match = context.matchBefore(/\w*/);
149
+ if (!match || match.from === match.to) {
150
+ return null;
151
+ }
152
+
153
+ const text = match.text.toUpperCase();
154
+ if (!context.explicit && match.text.length < 2) {
155
+ return null;
156
+ }
157
+
158
+ return {
159
+ from: match.from,
160
+ options: functions?.filter((name) => name.startsWith(text)).map((name) => createCompletion(name)) ?? [],
161
+ };
162
+ },
163
+ }),
164
+
165
+ syntaxHighlighting(highlightStyles),
166
+ autocompletion({
167
+ aboveCursor: false,
168
+ defaultKeymap: true,
169
+ activateOnTyping: true,
170
+ // NOTE: Useful for debugging.
171
+ closeOnBlur: false,
172
+ icons: false,
173
+ tooltipClass: () =>
174
+ mx(
175
+ // TODO(burdon): Factor out fragments.
176
+ // TODO(burdon): Size to make width same as column.
177
+ '!-left-[1px] !top-[33px] !-m-0 border !border-t-0 [&>ul]:!min-w-[198px]',
178
+ '[&>ul>li[aria-selected]]:!bg-primary-700',
179
+ 'border-neutral-200 dark:border-neutral-700',
180
+ ),
181
+ }),
182
+ keymap.of([
183
+ {
184
+ key: 'Tab',
185
+ run: (view) => {
186
+ return completionStatus(view.state) === 'active' ? acceptCompletion(view) : startCompletion(view);
187
+ },
188
+ },
189
+ ]),
190
+
191
+ // Parsing.
192
+ // StateField.define({
193
+ // create: (state) => {},
194
+ // update: (value, tr) => {
195
+ // log.info('update');
196
+ // syntaxTree(tr.state).iterate({
197
+ // enter: ({ type, from, to }) => {
198
+ // log.info('node', { type: type.name, from, to });
199
+ // },
200
+ // });
201
+ // },
202
+ // }),
203
+ ];
204
+ };
205
+
206
+ export type CellRangeNotifier = (range: string) => void;
207
+
208
+ type Range = { from: number; to: number };
209
+
210
+ /**
211
+ * Tracks the currently active cell within a formula and provides a callback to modify it.
212
+ */
213
+ export const rangeExtension = (onInit: (notifier: CellRangeNotifier) => void): Extension => {
214
+ let view: EditorView;
215
+ let activeRange: Range | undefined;
216
+ const provider: CellRangeNotifier = (range: string) => {
217
+ if (activeRange) {
218
+ view.dispatch(
219
+ view.state.update({
220
+ changes: { ...activeRange, insert: range.toString() },
221
+ selection: { anchor: activeRange.from + range.length },
222
+ }),
223
+ );
224
+ }
225
+
226
+ view.focus();
227
+ };
228
+
229
+ return ViewPlugin.fromClass(
230
+ class {
231
+ constructor(_view: EditorView) {
232
+ view = _view;
233
+ onInit(provider);
234
+ }
235
+
236
+ update(view: ViewUpdate) {
237
+ const { anchor } = view.state.selection.ranges[0];
238
+
239
+ // Find first Range or cell at cursor.
240
+ activeRange = undefined;
241
+ const [language] = view.state.facet(languageFacet);
242
+ const { topNode } = language.parser.parse(view.state.doc.toString());
243
+ visitTree(topNode, ({ type, from, to }) => {
244
+ if (from <= anchor && to >= anchor) {
245
+ switch (type.name) {
246
+ case 'Function': {
247
+ // Mark but keep looking.
248
+ activeRange = { from: to, to };
249
+ break;
250
+ }
251
+
252
+ case 'RangeToken':
253
+ case 'CellToken':
254
+ activeRange = { from, to };
255
+ return true;
256
+ }
257
+ }
258
+
259
+ return false;
260
+ });
261
+
262
+ // Allow start of formula.
263
+ if (!activeRange && view.state.doc.toString()[0] === '=') {
264
+ activeRange = { from: 1, to: view.state.doc.toString().length };
265
+ }
266
+ }
267
+ },
268
+ );
269
+ };
270
+
271
+ /**
272
+ * Lezer parse result visitor.
273
+ */
274
+ const visitTree = (node: SyntaxNode, callback: (node: SyntaxNode) => boolean): boolean => {
275
+ if (callback(node)) {
276
+ return true;
277
+ }
278
+
279
+ for (let child = node.firstChild; child !== null; child = child.nextSibling) {
280
+ if (visitTree(child, callback)) {
281
+ return true;
282
+ }
283
+ }
284
+
285
+ return false;
286
+ };