@dxos/plugin-sheet 0.6.11-staging.e6894a4 → 0.6.12-main.5cc132e

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 (107) hide show
  1. package/dist/lib/browser/{SheetContainer-4XOKHKKZ.mjs → SheetContainer-Y7ZMFBAP.mjs} +582 -121
  2. package/dist/lib/browser/SheetContainer-Y7ZMFBAP.mjs.map +7 -0
  3. package/dist/lib/browser/{chunk-P7SSL3EG.mjs → chunk-GNNVBNCX.mjs} +61 -53
  4. package/dist/lib/browser/chunk-GNNVBNCX.mjs.map +7 -0
  5. package/dist/lib/browser/{chunk-FWGRE3EG.mjs → chunk-PGKZPKUD.mjs} +2 -2
  6. package/dist/lib/browser/chunk-VBF7YENS.mjs +8 -0
  7. package/dist/lib/browser/{chunk-FUAGSXA4.mjs → chunk-WUPTZUTX.mjs} +6 -3
  8. package/dist/lib/browser/chunk-WUPTZUTX.mjs.map +7 -0
  9. package/dist/lib/browser/index.mjs +29 -18
  10. package/dist/lib/browser/index.mjs.map +3 -3
  11. package/dist/lib/browser/meta.json +1 -1
  12. package/dist/lib/browser/testing.mjs +3 -3
  13. package/dist/lib/browser/types.mjs +1 -1
  14. package/dist/lib/node/{SheetContainer-IQT6TR4Z.cjs → SheetContainer-KEOKUKAQ.cjs} +528 -79
  15. package/dist/lib/node/SheetContainer-KEOKUKAQ.cjs.map +7 -0
  16. package/dist/lib/node/{chunk-5EPCDAZC.cjs → chunk-57PB2HPY.cjs} +5 -5
  17. package/dist/lib/node/{chunk-727C6YNP.cjs → chunk-6LWBQAQZ.cjs} +9 -9
  18. package/dist/lib/node/{chunk-DSYKOI4E.cjs → chunk-VJU3NPUJ.cjs} +8 -5
  19. package/dist/lib/node/chunk-VJU3NPUJ.cjs.map +7 -0
  20. package/dist/lib/node/{chunk-SVAIIXWQ.cjs → chunk-ZRQZFV5T.cjs} +76 -63
  21. package/dist/lib/node/chunk-ZRQZFV5T.cjs.map +7 -0
  22. package/dist/lib/node/index.cjs +43 -33
  23. package/dist/lib/node/index.cjs.map +3 -3
  24. package/dist/lib/node/meta.json +1 -1
  25. package/dist/lib/node/testing.cjs +6 -6
  26. package/dist/lib/node/types.cjs +9 -9
  27. package/dist/lib/node/types.cjs.map +1 -1
  28. package/dist/lib/node-esm/SheetContainer-Y7ZMFBAP.mjs +2231 -0
  29. package/dist/lib/node-esm/SheetContainer-Y7ZMFBAP.mjs.map +7 -0
  30. package/dist/lib/node-esm/chunk-GNNVBNCX.mjs +3243 -0
  31. package/dist/lib/node-esm/chunk-GNNVBNCX.mjs.map +7 -0
  32. package/dist/lib/node-esm/chunk-JRL5LGCE.mjs +18 -0
  33. package/dist/lib/node-esm/chunk-JRL5LGCE.mjs.map +7 -0
  34. package/dist/lib/node-esm/chunk-PGKZPKUD.mjs +175 -0
  35. package/dist/lib/node-esm/chunk-PGKZPKUD.mjs.map +7 -0
  36. package/dist/lib/node-esm/chunk-VBF7YENS.mjs +8 -0
  37. package/dist/lib/node-esm/chunk-VBF7YENS.mjs.map +7 -0
  38. package/dist/lib/node-esm/chunk-WUPTZUTX.mjs +85 -0
  39. package/dist/lib/node-esm/chunk-WUPTZUTX.mjs.map +7 -0
  40. package/dist/lib/node-esm/index.mjs +257 -0
  41. package/dist/lib/node-esm/index.mjs.map +7 -0
  42. package/dist/lib/node-esm/meta.json +1 -0
  43. package/dist/lib/node-esm/meta.mjs +9 -0
  44. package/dist/lib/node-esm/meta.mjs.map +7 -0
  45. package/dist/lib/node-esm/testing.mjs +92 -0
  46. package/dist/lib/node-esm/testing.mjs.map +7 -0
  47. package/dist/lib/node-esm/types.mjs +22 -0
  48. package/dist/lib/node-esm/types.mjs.map +7 -0
  49. package/dist/types/src/SheetPlugin.d.ts.map +1 -1
  50. package/dist/types/src/components/Sheet/Sheet.d.ts.map +1 -1
  51. package/dist/types/src/components/Sheet/Sheet.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/Sheet/decorations.d.ts +24 -0
  53. package/dist/types/src/components/Sheet/decorations.d.ts.map +1 -0
  54. package/dist/types/src/components/Sheet/formatting.d.ts.map +1 -1
  55. package/dist/types/src/components/Sheet/sheet-context.d.ts +2 -0
  56. package/dist/types/src/components/Sheet/sheet-context.d.ts.map +1 -1
  57. package/dist/types/src/components/Sheet/threads.d.ts +2 -0
  58. package/dist/types/src/components/Sheet/threads.d.ts.map +1 -0
  59. package/dist/types/src/components/SheetContainer.d.ts +2 -3
  60. package/dist/types/src/components/SheetContainer.d.ts.map +1 -1
  61. package/dist/types/src/components/Toolbar/Toolbar.d.ts +19 -3
  62. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  63. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts +17 -12
  64. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  65. package/dist/types/src/components/index.d.ts +1 -2
  66. package/dist/types/src/components/index.d.ts.map +1 -1
  67. package/dist/types/src/model/index.d.ts +1 -0
  68. package/dist/types/src/model/index.d.ts.map +1 -1
  69. package/dist/types/src/model/model.d.ts +0 -16
  70. package/dist/types/src/model/model.d.ts.map +1 -1
  71. package/dist/types/src/model/util.d.ts +24 -0
  72. package/dist/types/src/model/util.d.ts.map +1 -1
  73. package/dist/types/src/translations.d.ts +17 -12
  74. package/dist/types/src/translations.d.ts.map +1 -1
  75. package/dist/types/src/types.d.ts +72 -2
  76. package/dist/types/src/types.d.ts.map +1 -1
  77. package/package.json +36 -32
  78. package/src/SheetPlugin.tsx +19 -20
  79. package/src/components/CellEditor/extension.test.ts +1 -2
  80. package/src/components/ComputeGraph/graph.browser.test.ts +1 -2
  81. package/src/components/Sheet/Sheet.stories.tsx +11 -9
  82. package/src/components/Sheet/Sheet.tsx +57 -29
  83. package/src/components/Sheet/decorations.ts +62 -0
  84. package/src/components/Sheet/formatting.ts +3 -3
  85. package/src/components/Sheet/sheet-context.tsx +9 -1
  86. package/src/components/Sheet/threads.tsx +201 -0
  87. package/src/components/SheetContainer.tsx +72 -20
  88. package/src/components/Toolbar/Toolbar.stories.tsx +5 -10
  89. package/src/components/Toolbar/Toolbar.tsx +54 -12
  90. package/src/model/index.ts +1 -0
  91. package/src/model/model.browser.test.ts +1 -2
  92. package/src/model/model.ts +11 -46
  93. package/src/model/types.test.ts +1 -2
  94. package/src/model/util.ts +67 -0
  95. package/src/translations.ts +6 -1
  96. package/src/types.ts +26 -3
  97. package/dist/lib/browser/SheetContainer-4XOKHKKZ.mjs.map +0 -7
  98. package/dist/lib/browser/chunk-FUAGSXA4.mjs.map +0 -7
  99. package/dist/lib/browser/chunk-P7SSL3EG.mjs.map +0 -7
  100. package/dist/lib/browser/chunk-YPU3R7FA.mjs +0 -8
  101. package/dist/lib/node/SheetContainer-IQT6TR4Z.cjs.map +0 -7
  102. package/dist/lib/node/chunk-DSYKOI4E.cjs.map +0 -7
  103. package/dist/lib/node/chunk-SVAIIXWQ.cjs.map +0 -7
  104. /package/dist/lib/browser/{chunk-FWGRE3EG.mjs.map → chunk-PGKZPKUD.mjs.map} +0 -0
  105. /package/dist/lib/browser/{chunk-YPU3R7FA.mjs.map → chunk-VBF7YENS.mjs.map} +0 -0
  106. /package/dist/lib/node/{chunk-5EPCDAZC.cjs.map → chunk-57PB2HPY.cjs.map} +0 -0
  107. /package/dist/lib/node/{chunk-727C6YNP.cjs.map → chunk-6LWBQAQZ.cjs.map} +0 -0
@@ -0,0 +1,201 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { effect } from '@preact/signals-core';
6
+ import React, { useCallback, useEffect, useMemo } from 'react';
7
+
8
+ import { type IntentResolver, LayoutAction, useIntentDispatcher, useIntentResolver } from '@dxos/app-framework';
9
+ import { debounce } from '@dxos/async';
10
+ import { fullyQualifiedId } from '@dxos/react-client/echo';
11
+ import { Icon, useTranslation } from '@dxos/react-ui';
12
+ import { mx } from '@dxos/react-ui-theme';
13
+
14
+ import { type Decoration } from './decorations';
15
+ import { useSheetContext } from './sheet-context';
16
+ import { SHEET_PLUGIN } from '../../meta';
17
+ import { addressFromIndex, addressToIndex, type CellAddress, closest } from '../../model';
18
+
19
+ const CommentIndicator = () => {
20
+ return (
21
+ <div
22
+ role='none'
23
+ className='absolute top-0 right-0 w-0 h-0 border-t-8 border-l-8 border-t-cmCommentSurface border-l-transparent'
24
+ />
25
+ );
26
+ };
27
+
28
+ const ThreadedCellWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => {
29
+ const dispatch = useIntentDispatcher();
30
+ const [isHovered, setIsHovered] = React.useState(true);
31
+ const { t } = useTranslation(SHEET_PLUGIN);
32
+
33
+ const handleClick = React.useCallback(
34
+ (_event: React.MouseEvent) => {
35
+ void dispatch({ action: LayoutAction.SET_LAYOUT, data: { element: 'complementary', state: true } });
36
+ },
37
+ [dispatch],
38
+ );
39
+
40
+ return (
41
+ <div
42
+ role='none'
43
+ className={mx('relative h-full is-full')}
44
+ onMouseEnter={() => {
45
+ setIsHovered(true);
46
+ }}
47
+ onMouseLeave={() => {
48
+ setIsHovered(false);
49
+ }}
50
+ >
51
+ <CommentIndicator />
52
+ {isHovered && (
53
+ <div className='absolute inset-0 flex items-center justify-end pr-1'>
54
+ <button
55
+ className='ch-button text-xs min-bs-0 p-1'
56
+ onClick={handleClick}
57
+ aria-label={t('open comment for sheet cell')}
58
+ >
59
+ <Icon icon='ph--chat--regular' aria-hidden={true} />
60
+ </button>
61
+ </div>
62
+ )}
63
+ {children}
64
+ </div>
65
+ );
66
+ };
67
+
68
+ const createThreadDecoration = (cellIndex: string, threadId: string, sheetId: string): Decoration => {
69
+ return {
70
+ type: 'comment',
71
+ cellIndex,
72
+ decorate: (props) => <ThreadedCellWrapper {...props} />,
73
+ };
74
+ };
75
+
76
+ const useUpdateCursorOnThreadSelection = () => {
77
+ const { setCursor, model } = useSheetContext();
78
+
79
+ const handleScrollIntoView: IntentResolver = useCallback(
80
+ ({ action, data }) => {
81
+ switch (action) {
82
+ case LayoutAction.SCROLL_INTO_VIEW: {
83
+ if (!data?.id || data?.cursor === undefined || data?.id !== fullyQualifiedId(model.sheet)) {
84
+ return;
85
+ }
86
+
87
+ // TODO(Zan): Everywhere we refer to the cursor in a thread context should change to `anchor`.
88
+ const cellAddress = addressFromIndex(model.sheet, data.cursor);
89
+ setCursor(cellAddress);
90
+ }
91
+ }
92
+ },
93
+ [model.sheet, setCursor],
94
+ );
95
+
96
+ useIntentResolver(SHEET_PLUGIN, handleScrollIntoView);
97
+ };
98
+
99
+ const useSelectThreadOnCursorChange = () => {
100
+ const { cursor, model } = useSheetContext();
101
+ const dispatch = useIntentDispatcher();
102
+
103
+ const activeThreads = useMemo(
104
+ () =>
105
+ model.sheet.threads?.filter(
106
+ (thread): thread is NonNullable<typeof thread> => !!thread && thread.status === 'active',
107
+ ) ?? [],
108
+ [JSON.stringify(model.sheet.threads)],
109
+ );
110
+
111
+ const activeThreadAddresses = useMemo(
112
+ () =>
113
+ activeThreads
114
+ .map((thread) => thread.anchor)
115
+ .filter((anchor): anchor is NonNullable<typeof anchor> => anchor !== undefined)
116
+ .map((anchor) => addressFromIndex(model.sheet, anchor)),
117
+ [activeThreads, model.sheet],
118
+ );
119
+
120
+ const selectClosestThread = useCallback(
121
+ (cellAddress: CellAddress) => {
122
+ if (!cellAddress || !activeThreads) {
123
+ return;
124
+ }
125
+
126
+ const closestThreadAnchor = closest(cellAddress, activeThreadAddresses);
127
+ if (closestThreadAnchor) {
128
+ const closestThread = activeThreads.find(
129
+ (thread) => thread && thread.anchor === addressToIndex(model.sheet, closestThreadAnchor),
130
+ );
131
+
132
+ if (closestThread) {
133
+ void dispatch([
134
+ { action: 'dxos.org/plugin/thread/action/select', data: { current: fullyQualifiedId(closestThread) } },
135
+ ]);
136
+ }
137
+ }
138
+ },
139
+ [dispatch, activeThreads, activeThreadAddresses, model.sheet],
140
+ );
141
+
142
+ const debounced = useMemo(() => {
143
+ return debounce((cursor: CellAddress) => requestAnimationFrame(() => selectClosestThread(cursor)), 50);
144
+ }, [selectClosestThread]);
145
+
146
+ useEffect(() => {
147
+ if (!cursor) {
148
+ return;
149
+ }
150
+ debounced(cursor);
151
+ }, [cursor, selectClosestThread]);
152
+ };
153
+
154
+ const useThreadDecorations = () => {
155
+ const { decorations, model } = useSheetContext();
156
+ const sheet = useMemo(() => model.sheet, [model.sheet]);
157
+ const sheetId = useMemo(() => fullyQualifiedId(sheet), [sheet]);
158
+
159
+ useEffect(() => {
160
+ const unsubscribe = effect(() => {
161
+ const activeThreadAnchors = new Set<string>();
162
+ if (!sheet.threads) {
163
+ return;
164
+ }
165
+
166
+ // Process active threads
167
+ for (const thread of sheet.threads) {
168
+ if (!thread || thread.anchor === undefined || thread.status === 'resolved') {
169
+ continue;
170
+ }
171
+
172
+ activeThreadAnchors.add(thread.anchor);
173
+ const index = thread.anchor;
174
+
175
+ // Add decoration only if it doesn't already exist
176
+ const existingDecorations = decorations.getDecorationsForCell(index);
177
+ if (!existingDecorations || !existingDecorations.some((d) => d.type === 'comment')) {
178
+ decorations.addDecoration(index, createThreadDecoration(index, thread.id, sheetId));
179
+ }
180
+ }
181
+
182
+ // Remove decorations for resolved or deleted threads
183
+ for (const decoration of decorations.getAllDecorations()) {
184
+ if (decoration.type !== 'comment') {
185
+ continue;
186
+ }
187
+
188
+ if (!activeThreadAnchors.has(decoration.cellIndex)) {
189
+ decorations.removeDecoration(decoration.cellIndex, 'comment');
190
+ }
191
+ }
192
+ });
193
+ return () => unsubscribe();
194
+ });
195
+ };
196
+
197
+ export const useThreads = () => {
198
+ useUpdateCursorOnThreadSelection();
199
+ useSelectThreadOnCursorChange();
200
+ useThreadDecorations();
201
+ };
@@ -2,32 +2,84 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import React from 'react';
5
+ import React, { useCallback } from 'react';
6
6
 
7
- import { type LayoutCoordinate } from '@dxos/app-framework';
8
- import { mx } from '@dxos/react-ui-theme';
7
+ import { useIntentDispatcher } from '@dxos/app-framework';
8
+ import { fullyQualifiedId } from '@dxos/react-client/echo';
9
+ import { useIsDirectlyAttended } from '@dxos/react-ui-attention';
10
+ import { focusRing, mx } from '@dxos/react-ui-theme';
9
11
 
10
12
  import { Sheet, type SheetRootProps } from './Sheet';
13
+ import { Toolbar, type ToolbarAction } from './Toolbar';
14
+
15
+ // TODO(Zan): Factor out, copied this from MarkdownPlugin.
16
+ const attentionFragment = mx(
17
+ 'group-focus-within/editor:attention-surface group-[[aria-current]]/editor:attention-surface',
18
+ 'group-focus-within/editor:border-separator',
19
+ );
20
+
21
+ // TODO(Zan): Factor out, copied this from MarkdownPlugin.
22
+ export const sectionToolbarLayout =
23
+ 'bs-[--rail-action] bg-[--sticky-bg] sticky block-start-0 __-block-start-px transition-opacity';
24
+
25
+ const SheetContainer = ({ sheet, space, role, remoteFunctionUrl }: SheetRootProps & { role?: string }) => {
26
+ const dispatch = useIntentDispatcher();
27
+
28
+ const id = fullyQualifiedId(sheet);
29
+ const isDirectlyAttended = useIsDirectlyAttended(id);
30
+
31
+ // TODO(Zan): Centralise the toolbar action handler. Current implementation in stories.
32
+ const handleAction = useCallback(
33
+ (action: ToolbarAction) => {
34
+ switch (action.type) {
35
+ case 'comment': {
36
+ // TODO(Zan): We shouldn't hardcode the action ID.
37
+ void dispatch({
38
+ action: 'dxos.org/plugin/thread/action/create',
39
+ data: {
40
+ cursor: action.anchor,
41
+ name: action.cellContent,
42
+ subject: sheet,
43
+ },
44
+ });
45
+ }
46
+ }
47
+ },
48
+ [sheet, dispatch],
49
+ );
11
50
 
12
- const SheetContainer = ({
13
- sheet,
14
- space,
15
- role,
16
- coordinate = { part: 'main', entryId: '' },
17
- remoteFunctionUrl,
18
- }: SheetRootProps & { role?: string; coordinate?: LayoutCoordinate }) => {
19
51
  return (
20
- <div
21
- role='none'
22
- className={mx(
23
- 'flex',
24
- role === 'article' && 'row-span-2', // TODO(burdon): Container with toolbar.
25
- role === 'section' && 'aspect-square border-y border-is border-separator',
26
- coordinate.part !== 'solo' && 'border-is border-separator',
27
- )}
28
- >
52
+ <div role='none' className={role === 'article' ? 'row-span-2 grid grid-rows-subgrid' : undefined}>
29
53
  <Sheet.Root sheet={sheet} space={space} remoteFunctionUrl={remoteFunctionUrl}>
30
- <Sheet.Main />
54
+ <div role='none' className={mx('flex flex-0 justify-center overflow-x-auto')}>
55
+ <Toolbar.Root
56
+ onAction={handleAction}
57
+ classNames={mx(
58
+ role === 'section'
59
+ ? ['z-[2] group-focus-within/section:visible', !isDirectlyAttended && 'invisible', sectionToolbarLayout]
60
+ : 'group-focus-within/editor:border-separator group-[[aria-current]]/editor:border-separator',
61
+ )}
62
+ >
63
+ {/* TODO(Zan): Restore some of this functionality */}
64
+ {/* <Toolbar.Styles /> */}
65
+ {/* <Toolbar.Format /> */}
66
+ {/* <Toolbar.Alignment /> */}
67
+ <Toolbar.Separator />
68
+ <Toolbar.Actions />
69
+ </Toolbar.Root>
70
+ </div>
71
+ <div
72
+ role='none'
73
+ className={mx(
74
+ role === 'section' && 'aspect-square border-is border-bs border-be border-separator',
75
+ role === 'article' &&
76
+ 'flex is-full overflow-hidden focus-visible:ring-inset row-span-1 data-[toolbar=disabled]:pbs-2 data-[toolbar=disabled]:row-span-2 border-bs border-separator',
77
+ focusRing,
78
+ attentionFragment,
79
+ )}
80
+ >
81
+ <Sheet.Main />
82
+ </div>
31
83
  </Sheet.Root>
32
84
  </div>
33
85
  );
@@ -6,29 +6,24 @@ import '@dxos-theme';
6
6
 
7
7
  import React from 'react';
8
8
 
9
- import { Tooltip } from '@dxos/react-ui';
10
9
  import { textBlockWidth } from '@dxos/react-ui-theme';
11
- import { withTheme } from '@dxos/storybook-utils';
10
+ import { withLayout, withTheme } from '@dxos/storybook-utils';
12
11
 
13
12
  import { Toolbar } from './Toolbar';
14
13
  import translations from '../../translations';
15
14
 
16
15
  const Story = () => {
17
16
  return (
18
- <Tooltip.Provider>
19
- <div role='none' className='fixed inset-0 flex flex-col'>
20
- <Toolbar.Root classNames={textBlockWidth}>
21
- <Toolbar.Alignment />
22
- </Toolbar.Root>
23
- </div>
24
- </Tooltip.Provider>
17
+ <Toolbar.Root classNames={textBlockWidth}>
18
+ <Toolbar.Alignment />
19
+ </Toolbar.Root>
25
20
  );
26
21
  };
27
22
 
28
23
  export default {
29
24
  title: 'plugin-sheet/Toolbar',
30
25
  component: Toolbar,
31
- decorators: [withTheme],
26
+ decorators: [withTheme, withLayout({ tooltips: true })],
32
27
  parameters: { translations, layout: 'fullscreen' },
33
28
  render: (args: any) => <Story {...args} />,
34
29
  };
@@ -23,22 +23,31 @@ import {
23
23
  type ThemedClassName,
24
24
  useTranslation,
25
25
  } from '@dxos/react-ui';
26
+ import { nonNullable } from '@dxos/util';
26
27
 
27
28
  import { ToolbarButton, ToolbarSeparator, ToolbarToggleButton } from './common';
28
29
  import { SHEET_PLUGIN } from '../../meta';
30
+ import { addressToIndex } from '../../model';
29
31
  import { type Formatting } from '../../types';
32
+ import { useSheetContext } from '../Sheet/sheet-context';
30
33
 
31
34
  //
32
35
  // Root
33
36
  //
34
37
 
35
- export type ToolbarActionType = 'clear' | 'highlight' | 'left' | 'center' | 'right' | 'date' | 'currency';
38
+ export type ToolbarAction =
39
+ | { type: 'clear' }
40
+ | { type: 'highlight' }
41
+ | { type: 'left' }
42
+ | { type: 'center' }
43
+ | { type: 'right' }
44
+ | { type: 'date' }
45
+ | { type: 'currency' }
46
+ | { type: 'comment'; anchor: string; cellContent?: string };
36
47
 
37
- export type ToolbarAction = {
38
- type: ToolbarActionType;
39
- };
48
+ export type ToolbarActionType = ToolbarAction['type'];
40
49
 
41
- export type ToolbarActionHandler = ({ type }: ToolbarAction) => void;
50
+ export type ToolbarActionHandler = (action: ToolbarAction) => void;
42
51
 
43
52
  export type ToolbarProps = ThemedClassName<
44
53
  PropsWithChildren<{
@@ -96,7 +105,7 @@ const Format = () => {
96
105
  Icon={Icon}
97
106
  // disabled={state?.blockType === 'codeblock'}
98
107
  // onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
99
- onClick={() => onAction?.({ type })}
108
+ onClick={() => onAction?.({ type: type as Exclude<typeof type, 'comment'> })}
100
109
  >
101
110
  {t(`toolbar ${type} label`)}
102
111
  </ToolbarToggleButton>
@@ -127,7 +136,7 @@ const Alignment = () => {
127
136
  Icon={Icon}
128
137
  // disabled={state?.blockType === 'codeblock'}
129
138
  // onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
130
- onClick={() => onAction?.({ type })}
139
+ onClick={() => onAction?.({ type: type as Exclude<typeof type, 'comment'> })}
131
140
  >
132
141
  {t(`toolbar ${type} label`)}
133
142
  </ToolbarToggleButton>
@@ -157,7 +166,7 @@ const Styles = () => {
157
166
  Icon={Icon}
158
167
  // disabled={state?.blockType === 'codeblock'}
159
168
  // onClick={state ? () => onAction?.({ type, data: !getState(state) }) : undefined}
160
- onClick={() => onAction?.({ type })}
169
+ onClick={() => onAction?.({ type: type as Exclude<typeof type, 'comment'> })}
161
170
  >
162
171
  {t(`toolbar ${type} label`)}
163
172
  </ToolbarToggleButton>
@@ -170,18 +179,51 @@ const Styles = () => {
170
179
  // Actions
171
180
  //
172
181
 
182
+ // TODO(Zan): Instead of taking props, can we access the state from sheet context?
173
183
  const Actions = () => {
174
- // const { onAction } = useToolbarContext('Actions');
184
+ const { onAction } = useToolbarContext('Actions');
185
+ const { cursor, range, model } = useSheetContext();
175
186
  const { t } = useTranslation(SHEET_PLUGIN);
187
+
188
+ const overlapsCommentAnchor = (model.sheet.threads ?? [])
189
+ .filter(nonNullable)
190
+ .filter((thread) => thread.status !== 'resolved')
191
+ .some((thread) => {
192
+ if (!cursor) {
193
+ return false;
194
+ }
195
+ return addressToIndex(model.sheet, cursor) === thread.anchor;
196
+ });
197
+
198
+ const hasCursor = !!cursor;
199
+ const cursorOnly = hasCursor && !range && !overlapsCommentAnchor;
200
+
201
+ const tooltipLabelKey = !hasCursor
202
+ ? 'no cursor label'
203
+ : overlapsCommentAnchor
204
+ ? 'selection overlaps existing comment label'
205
+ : range
206
+ ? 'comment ranges not supported label'
207
+ : 'comment label';
208
+
176
209
  return (
177
210
  <ToolbarButton
178
211
  value='comment'
179
212
  Icon={ChatText}
180
213
  data-testid='editor.toolbar.comment'
181
- // onClick={() => onAction?.({ type: 'comment' })}
182
- // disabled={!state || state.comment || !state.selection}
214
+ onClick={() => {
215
+ if (!cursor) {
216
+ return;
217
+ }
218
+ return onAction?.({
219
+ type: 'comment',
220
+ anchor: addressToIndex(model.sheet, cursor),
221
+ cellContent: model.getCellText(cursor),
222
+ });
223
+ }}
224
+ disabled={!cursorOnly || overlapsCommentAnchor}
183
225
  >
184
- {t('comment label')}
226
+ {t(tooltipLabelKey)}
185
227
  </ToolbarButton>
186
228
  );
187
229
  };
@@ -5,3 +5,4 @@
5
5
  export * from './functions';
6
6
  export * from './model';
7
7
  export * from './types';
8
+ export * from './util';
@@ -2,8 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { expect } from 'chai';
6
- import { describe, test } from 'vitest';
5
+ import { describe, expect, test } from 'vitest';
7
6
 
8
7
  import { SheetModel } from './model';
9
8
  import { addressFromA1Notation, rangeFromA1Notation } from './types';
@@ -17,13 +17,12 @@ import { type FunctionType } from '@dxos/plugin-script/types';
17
17
 
18
18
  import { defaultFunctions, type FunctionDefinition } from './functions';
19
19
  import { addressFromA1Notation, addressToA1Notation, type CellAddress, type CellRange } from './types';
20
- import { createIndices, RangeException, ReadonlyException } from './util';
20
+ import { addressFromIndex, addressToIndex, createIndices, RangeException, ReadonlyException } from './util';
21
21
  import { type ComputeGraph } from '../components';
22
22
  import { type CellScalarValue, type CellValue, type SheetType, ValueTypeEnum } from '../types';
23
23
 
24
- // TODO(burdon): Defaults or Max?
25
- const DEFAULT_ROWS = 500;
26
- const DEFAULT_COLUMNS = 26 * 2;
24
+ const DEFAULT_ROWS = 100;
25
+ const DEFAULT_COLUMNS = 26;
27
26
 
28
27
  export type CellIndex = string;
29
28
 
@@ -187,7 +186,7 @@ export class SheetModel {
187
186
  reset() {
188
187
  this._graph.hf.clearSheet(this._sheetId);
189
188
  Object.entries(this._sheet.cells).forEach(([key, { value }]) => {
190
- const { column, row } = this.addressFromIndex(key);
189
+ const { column, row } = addressFromIndex(this._sheet, key);
191
190
  if (typeof value === 'string' && value.charAt(0) === '=') {
192
191
  value = this.mapFormulaBindingToFormula(this.mapFormulaBindingFromId(this.mapFormulaIndicesToRefs(value)));
193
192
  }
@@ -221,7 +220,6 @@ export class SheetModel {
221
220
  // Undoable actions.
222
221
  // TODO(burdon): Group undoable methods; consistently update hf/sheet.
223
222
  //
224
-
225
223
  /**
226
224
  * Clear range of values.
227
225
  */
@@ -230,7 +228,7 @@ export class SheetModel {
230
228
  const values = this._iterRange(range, () => null);
231
229
  this._graph.hf.setCellContents(toSimpleCellAddress(this._sheetId, topLeft), values);
232
230
  this._iterRange(range, (cell) => {
233
- const idx = this.addressToIndex(cell);
231
+ const idx = addressToIndex(this._sheet, cell);
234
232
  delete this._sheet.cells[idx];
235
233
  });
236
234
  }
@@ -238,7 +236,7 @@ export class SheetModel {
238
236
  cut(range: CellRange) {
239
237
  this._graph.hf.cut(toModelRange(this._sheetId, range));
240
238
  this._iterRange(range, (cell) => {
241
- const idx = this.addressToIndex(cell);
239
+ const idx = addressToIndex(this._sheet, cell);
242
240
  delete this._sheet.cells[idx];
243
241
  });
244
242
  }
@@ -253,7 +251,7 @@ export class SheetModel {
253
251
  for (const change of changes) {
254
252
  if (change instanceof ExportedCellChange) {
255
253
  const { address, newValue } = change;
256
- const idx = this.addressToIndex({ row: address.row, column: address.col });
254
+ const idx = addressToIndex(this._sheet, { row: address.row, column: address.col });
257
255
  this._sheet.cells[idx] = { value: newValue };
258
256
  }
259
257
  }
@@ -279,7 +277,7 @@ export class SheetModel {
279
277
  * Get value from sheet.
280
278
  */
281
279
  getCellValue(cell: CellAddress): CellScalarValue {
282
- const idx = this.addressToIndex(cell);
280
+ const idx = addressToIndex(this._sheet, cell);
283
281
  return this._sheet.cells[idx]?.value ?? null;
284
282
  }
285
283
 
@@ -357,7 +355,7 @@ export class SheetModel {
357
355
  ]);
358
356
 
359
357
  // Insert into sheet.
360
- const idx = this.addressToIndex(cell);
358
+ const idx = addressToIndex(this._sheet, cell);
361
359
  if (value === undefined || value === null) {
362
360
  delete this._sheet.cells[idx];
363
361
  } else {
@@ -427,39 +425,6 @@ export class SheetModel {
427
425
  // Indices.
428
426
  //
429
427
 
430
- /**
431
- * E.g., "A1" => "x1@y1".
432
- */
433
- addressToIndex(cell: CellAddress): CellIndex {
434
- return `${this._sheet.columns[cell.column]}@${this._sheet.rows[cell.row]}`;
435
- }
436
-
437
- /**
438
- * E.g., "x1@y1" => "A1".
439
- */
440
- addressFromIndex(idx: CellIndex): CellAddress {
441
- const [column, row] = idx.split('@');
442
- return {
443
- column: this._sheet.columns.indexOf(column),
444
- row: this._sheet.rows.indexOf(row),
445
- };
446
- }
447
-
448
- /**
449
- * E.g., "A1:B2" => "x1@y1:x2@y2".
450
- */
451
- rangeToIndex(range: CellRange): string {
452
- return [range.from, range.to ?? range.from].map((cell) => this.addressToIndex(cell)).join(':');
453
- }
454
-
455
- /**
456
- * E.g., "x1@y1:x2@y2" => "A1:B2".
457
- */
458
- rangeFromIndex(idx: string): CellRange {
459
- const [from, to] = idx.split(':').map((idx) => this.addressFromIndex(idx));
460
- return { from, to };
461
- }
462
-
463
428
  /**
464
429
  * E.g., "HELLO()" => "EDGE("HELLO")".
465
430
  */
@@ -509,7 +474,7 @@ export class SheetModel {
509
474
  mapFormulaRefsToIndices(formula: string): string {
510
475
  invariant(formula.charAt(0) === '=');
511
476
  return formula.replace(/([a-zA-Z]+)([0-9]+)/g, (match) => {
512
- return this.addressToIndex(addressFromA1Notation(match));
477
+ return addressToIndex(this._sheet, addressFromA1Notation(match));
513
478
  });
514
479
  }
515
480
 
@@ -519,7 +484,7 @@ export class SheetModel {
519
484
  mapFormulaIndicesToRefs(formula: string): string {
520
485
  invariant(formula.charAt(0) === '=');
521
486
  return formula.replace(/([a-zA-Z0-9]+)@([a-zA-Z0-9]+)/g, (idx) => {
522
- return addressToA1Notation(this.addressFromIndex(idx));
487
+ return addressToA1Notation(addressFromIndex(this._sheet, idx));
523
488
  });
524
489
  }
525
490
 
@@ -3,8 +3,7 @@
3
3
  //
4
4
 
5
5
  import { getIndices, sortByIndex, getIndicesBelow, getIndicesAbove, getIndicesBetween } from '@tldraw/indices';
6
- import { expect } from 'chai';
7
- import { describe, test } from 'vitest';
6
+ import { describe, expect, test } from 'vitest';
8
7
 
9
8
  import { inRange, addressFromA1Notation, addressToA1Notation, rangeFromA1Notation, rangeToA1Notation } from './types';
10
9