@dxos/plugin-markdown 0.8.3-main.672df60 → 0.8.3-staging.0fa589b

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 (159) hide show
  1. package/dist/lib/browser/{MarkdownContainer-DGPA7SEG.mjs → MarkdownContainer-EFWQ6DHD.mjs} +231 -49
  2. package/dist/lib/browser/MarkdownContainer-EFWQ6DHD.mjs.map +7 -0
  3. package/dist/lib/browser/{MarkdownPreview-KQN2TGK6.mjs → MarkdownPreview-F4PYFW5L.mjs} +8 -8
  4. package/dist/lib/browser/MarkdownPreview-F4PYFW5L.mjs.map +7 -0
  5. package/dist/lib/browser/{anchor-sort-VS4OZVPP.mjs → anchor-sort-BMAN2ABT.mjs} +4 -4
  6. package/dist/lib/browser/anchor-sort-BMAN2ABT.mjs.map +7 -0
  7. package/dist/lib/browser/{app-graph-serializer-V6RLEHVY.mjs → app-graph-serializer-EI6TEHRQ.mjs} +7 -5
  8. package/dist/lib/browser/app-graph-serializer-EI6TEHRQ.mjs.map +7 -0
  9. package/dist/lib/browser/{artifact-definition-5NAODQLG.mjs → artifact-definition-FQ2R6KPT.mjs} +6 -6
  10. package/dist/lib/browser/artifact-definition-FQ2R6KPT.mjs.map +7 -0
  11. package/dist/lib/browser/{chunk-OTOPAFWC.mjs → chunk-354IBM5X.mjs} +7 -7
  12. package/dist/lib/browser/{chunk-C5RABVIX.mjs → chunk-D767LUGU.mjs} +7 -3
  13. package/dist/lib/browser/chunk-D767LUGU.mjs.map +7 -0
  14. package/dist/lib/browser/{chunk-77NGW7EO.mjs → chunk-LXSRQPEP.mjs} +9 -9
  15. package/dist/lib/browser/chunk-LXSRQPEP.mjs.map +7 -0
  16. package/dist/lib/browser/{chunk-ECSM56YC.mjs → chunk-N2D26K6W.mjs} +4 -5
  17. package/dist/lib/browser/chunk-N2D26K6W.mjs.map +7 -0
  18. package/dist/lib/browser/index.mjs +7 -8
  19. package/dist/lib/browser/index.mjs.map +3 -3
  20. package/dist/lib/browser/{intent-resolver-4GDYST4Y.mjs → intent-resolver-6ZOABX2J.mjs} +6 -7
  21. package/dist/lib/browser/intent-resolver-6ZOABX2J.mjs.map +7 -0
  22. package/dist/lib/browser/meta.json +1 -1
  23. package/dist/lib/browser/{react-surface-DM6B4UUJ.mjs → react-surface-4B5ELMEW.mjs} +14 -9
  24. package/dist/lib/browser/react-surface-4B5ELMEW.mjs.map +7 -0
  25. package/dist/lib/browser/{settings-W5CK4PXP.mjs → settings-PLH54VC7.mjs} +4 -4
  26. package/dist/lib/browser/settings-PLH54VC7.mjs.map +7 -0
  27. package/dist/lib/browser/types/index.mjs +1 -1
  28. package/dist/lib/node/{MarkdownContainer-BZK66EQD.cjs → MarkdownContainer-KYGUEZIL.cjs} +277 -95
  29. package/dist/lib/node/MarkdownContainer-KYGUEZIL.cjs.map +7 -0
  30. package/dist/lib/node/{MarkdownPreview-H7FFIWJH.cjs → MarkdownPreview-GCJJCXY6.cjs} +17 -17
  31. package/dist/lib/node/MarkdownPreview-GCJJCXY6.cjs.map +7 -0
  32. package/dist/lib/node/{anchor-sort-NHVF23EU.cjs → anchor-sort-V3T4SFFI.cjs} +12 -12
  33. package/dist/lib/node/anchor-sort-V3T4SFFI.cjs.map +7 -0
  34. package/dist/lib/node/{app-graph-serializer-CLALIYN3.cjs → app-graph-serializer-E6BXEDEL.cjs} +14 -12
  35. package/dist/lib/node/app-graph-serializer-E6BXEDEL.cjs.map +7 -0
  36. package/dist/lib/node/{artifact-definition-VEAHK7BX.cjs → artifact-definition-U27MH5SC.cjs} +16 -16
  37. package/dist/lib/node/artifact-definition-U27MH5SC.cjs.map +7 -0
  38. package/dist/lib/node/{chunk-RQS4KBMG.cjs → chunk-3HHV4MM6.cjs} +6 -7
  39. package/dist/lib/node/chunk-3HHV4MM6.cjs.map +7 -0
  40. package/dist/lib/node/{chunk-G7RBJX22.cjs → chunk-CJLYFGPI.cjs} +12 -12
  41. package/dist/lib/node/chunk-CJLYFGPI.cjs.map +7 -0
  42. package/dist/lib/node/{chunk-BBDDZLQH.cjs → chunk-FU3XZZCO.cjs} +10 -10
  43. package/dist/lib/node/{chunk-ZDTL47I7.cjs → chunk-LQAC5HL7.cjs} +10 -6
  44. package/dist/lib/node/chunk-LQAC5HL7.cjs.map +7 -0
  45. package/dist/lib/node/index.cjs +26 -27
  46. package/dist/lib/node/index.cjs.map +3 -3
  47. package/dist/lib/node/{intent-resolver-AUZVK3NZ.cjs → intent-resolver-OEFLRNEJ.cjs} +14 -15
  48. package/dist/lib/node/intent-resolver-OEFLRNEJ.cjs.map +7 -0
  49. package/dist/lib/node/meta.json +1 -1
  50. package/dist/lib/node/{react-surface-QBW4FFXF.cjs → react-surface-456HQ4KZ.cjs} +21 -16
  51. package/dist/lib/node/react-surface-456HQ4KZ.cjs.map +7 -0
  52. package/dist/lib/node/{settings-IRKU3WPM.cjs → settings-E3NUTXJ4.cjs} +7 -7
  53. package/dist/lib/node/settings-E3NUTXJ4.cjs.map +7 -0
  54. package/dist/lib/node/types/index.cjs +7 -7
  55. package/dist/lib/node/types/index.cjs.map +1 -1
  56. package/dist/lib/node-esm/{MarkdownContainer-2DYVQ6RC.mjs → MarkdownContainer-O3SGMH4G.mjs} +231 -49
  57. package/dist/lib/node-esm/MarkdownContainer-O3SGMH4G.mjs.map +7 -0
  58. package/dist/lib/node-esm/{MarkdownPreview-JUIXYYKF.mjs → MarkdownPreview-KFDRV4GC.mjs} +8 -8
  59. package/dist/lib/node-esm/MarkdownPreview-KFDRV4GC.mjs.map +7 -0
  60. package/dist/lib/node-esm/{anchor-sort-G2HLCYFK.mjs → anchor-sort-BXL7BE67.mjs} +4 -4
  61. package/dist/lib/node-esm/anchor-sort-BXL7BE67.mjs.map +7 -0
  62. package/dist/lib/node-esm/{app-graph-serializer-C3RNTQGM.mjs → app-graph-serializer-F7DGNF3G.mjs} +7 -5
  63. package/dist/lib/node-esm/app-graph-serializer-F7DGNF3G.mjs.map +7 -0
  64. package/dist/lib/node-esm/{artifact-definition-7TIJW2CO.mjs → artifact-definition-NQOHB6S5.mjs} +6 -6
  65. package/dist/lib/node-esm/artifact-definition-NQOHB6S5.mjs.map +7 -0
  66. package/dist/lib/node-esm/{chunk-TCFJNUAE.mjs → chunk-BWDDFDJY.mjs} +7 -3
  67. package/dist/lib/node-esm/chunk-BWDDFDJY.mjs.map +7 -0
  68. package/dist/lib/node-esm/{chunk-6RPARLIK.mjs → chunk-K26TX5V4.mjs} +9 -9
  69. package/dist/lib/node-esm/chunk-K26TX5V4.mjs.map +7 -0
  70. package/dist/lib/node-esm/{chunk-NCMPVEXO.mjs → chunk-T2Y2BT53.mjs} +4 -5
  71. package/dist/lib/node-esm/chunk-T2Y2BT53.mjs.map +7 -0
  72. package/dist/lib/node-esm/{chunk-KK7LP3UQ.mjs → chunk-YOABAQ7A.mjs} +7 -7
  73. package/dist/lib/node-esm/index.mjs +7 -8
  74. package/dist/lib/node-esm/index.mjs.map +3 -3
  75. package/dist/lib/node-esm/{intent-resolver-FTNXUNI2.mjs → intent-resolver-CLMSVF2K.mjs} +6 -7
  76. package/dist/lib/node-esm/intent-resolver-CLMSVF2K.mjs.map +7 -0
  77. package/dist/lib/node-esm/meta.json +1 -1
  78. package/dist/lib/node-esm/{react-surface-A4VAOQG6.mjs → react-surface-YHFOQTVO.mjs} +14 -9
  79. package/dist/lib/node-esm/react-surface-YHFOQTVO.mjs.map +7 -0
  80. package/dist/lib/node-esm/{settings-MK7D7LHQ.mjs → settings-SIY33P3F.mjs} +4 -4
  81. package/dist/lib/node-esm/settings-SIY33P3F.mjs.map +7 -0
  82. package/dist/lib/node-esm/types/index.mjs +1 -1
  83. package/dist/types/src/MarkdownPlugin.d.ts.map +1 -1
  84. package/dist/types/src/capabilities/app-graph-serializer.d.ts.map +1 -1
  85. package/dist/types/src/capabilities/intent-resolver.d.ts.map +1 -1
  86. package/dist/types/src/components/MarkdownContainer.d.ts +1 -1
  87. package/dist/types/src/components/MarkdownContainer.d.ts.map +1 -1
  88. package/dist/types/src/components/MarkdownContainer.stories.d.ts +9 -0
  89. package/dist/types/src/components/MarkdownContainer.stories.d.ts.map +1 -0
  90. package/dist/types/src/components/MarkdownEditor/MarkdownEditor.d.ts +4 -3
  91. package/dist/types/src/components/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  92. package/dist/types/src/components/MarkdownPreview/MarkdownPreview.stories.d.ts +3 -9
  93. package/dist/types/src/components/MarkdownPreview/MarkdownPreview.stories.d.ts.map +1 -1
  94. package/dist/types/src/components/Suggestions.stories.d.ts.map +1 -1
  95. package/dist/types/src/extensions.d.ts +3 -2
  96. package/dist/types/src/extensions.d.ts.map +1 -1
  97. package/dist/types/src/translations.d.ts +52 -2
  98. package/dist/types/src/translations.d.ts.map +1 -1
  99. package/dist/types/src/types/schema.d.ts +11 -14
  100. package/dist/types/src/types/schema.d.ts.map +1 -1
  101. package/dist/types/src/types/types.d.ts +5 -6
  102. package/dist/types/src/types/types.d.ts.map +1 -1
  103. package/dist/types/src/util.d.ts.map +1 -1
  104. package/package.json +38 -38
  105. package/src/MarkdownPlugin.tsx +3 -5
  106. package/src/capabilities/anchor-sort.ts +2 -2
  107. package/src/capabilities/app-graph-serializer.ts +5 -3
  108. package/src/capabilities/artifact-definition.ts +3 -3
  109. package/src/capabilities/intent-resolver.ts +4 -5
  110. package/src/capabilities/react-surface.tsx +5 -5
  111. package/src/capabilities/settings.ts +2 -2
  112. package/src/components/MarkdownContainer.stories.tsx +103 -0
  113. package/src/components/MarkdownContainer.tsx +149 -52
  114. package/src/components/MarkdownEditor/MarkdownEditor.stories.tsx +1 -1
  115. package/src/components/MarkdownEditor/MarkdownEditor.tsx +215 -139
  116. package/src/components/MarkdownPreview/MarkdownPreview.stories.tsx +8 -7
  117. package/src/components/MarkdownPreview/MarkdownPreview.tsx +5 -5
  118. package/src/components/Suggestions.stories.tsx +7 -9
  119. package/src/components/Toolbar.stories.tsx +3 -3
  120. package/src/extensions.tsx +7 -1
  121. package/src/translations.ts +6 -1
  122. package/src/types/schema.ts +2 -3
  123. package/src/util.tsx +5 -7
  124. package/dist/lib/browser/MarkdownContainer-DGPA7SEG.mjs.map +0 -7
  125. package/dist/lib/browser/MarkdownPreview-KQN2TGK6.mjs.map +0 -7
  126. package/dist/lib/browser/anchor-sort-VS4OZVPP.mjs.map +0 -7
  127. package/dist/lib/browser/app-graph-serializer-V6RLEHVY.mjs.map +0 -7
  128. package/dist/lib/browser/artifact-definition-5NAODQLG.mjs.map +0 -7
  129. package/dist/lib/browser/chunk-77NGW7EO.mjs.map +0 -7
  130. package/dist/lib/browser/chunk-C5RABVIX.mjs.map +0 -7
  131. package/dist/lib/browser/chunk-ECSM56YC.mjs.map +0 -7
  132. package/dist/lib/browser/intent-resolver-4GDYST4Y.mjs.map +0 -7
  133. package/dist/lib/browser/react-surface-DM6B4UUJ.mjs.map +0 -7
  134. package/dist/lib/browser/settings-W5CK4PXP.mjs.map +0 -7
  135. package/dist/lib/node/MarkdownContainer-BZK66EQD.cjs.map +0 -7
  136. package/dist/lib/node/MarkdownPreview-H7FFIWJH.cjs.map +0 -7
  137. package/dist/lib/node/anchor-sort-NHVF23EU.cjs.map +0 -7
  138. package/dist/lib/node/app-graph-serializer-CLALIYN3.cjs.map +0 -7
  139. package/dist/lib/node/artifact-definition-VEAHK7BX.cjs.map +0 -7
  140. package/dist/lib/node/chunk-G7RBJX22.cjs.map +0 -7
  141. package/dist/lib/node/chunk-RQS4KBMG.cjs.map +0 -7
  142. package/dist/lib/node/chunk-ZDTL47I7.cjs.map +0 -7
  143. package/dist/lib/node/intent-resolver-AUZVK3NZ.cjs.map +0 -7
  144. package/dist/lib/node/react-surface-QBW4FFXF.cjs.map +0 -7
  145. package/dist/lib/node/settings-IRKU3WPM.cjs.map +0 -7
  146. package/dist/lib/node-esm/MarkdownContainer-2DYVQ6RC.mjs.map +0 -7
  147. package/dist/lib/node-esm/MarkdownPreview-JUIXYYKF.mjs.map +0 -7
  148. package/dist/lib/node-esm/anchor-sort-G2HLCYFK.mjs.map +0 -7
  149. package/dist/lib/node-esm/app-graph-serializer-C3RNTQGM.mjs.map +0 -7
  150. package/dist/lib/node-esm/artifact-definition-7TIJW2CO.mjs.map +0 -7
  151. package/dist/lib/node-esm/chunk-6RPARLIK.mjs.map +0 -7
  152. package/dist/lib/node-esm/chunk-NCMPVEXO.mjs.map +0 -7
  153. package/dist/lib/node-esm/chunk-TCFJNUAE.mjs.map +0 -7
  154. package/dist/lib/node-esm/intent-resolver-FTNXUNI2.mjs.map +0 -7
  155. package/dist/lib/node-esm/react-surface-A4VAOQG6.mjs.map +0 -7
  156. package/dist/lib/node-esm/settings-MK7D7LHQ.mjs.map +0 -7
  157. /package/dist/lib/browser/{chunk-OTOPAFWC.mjs.map → chunk-354IBM5X.mjs.map} +0 -0
  158. /package/dist/lib/node/{chunk-BBDDZLQH.cjs.map → chunk-FU3XZZCO.cjs.map} +0 -0
  159. /package/dist/lib/node-esm/{chunk-KK7LP3UQ.mjs.map → chunk-YOABAQ7A.mjs.map} +0 -0
@@ -3,12 +3,23 @@
3
3
  //
4
4
 
5
5
  import { Rx } from '@effect-rx/rx-react';
6
- import React, { useEffect, useMemo } from 'react';
6
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
7
+ import { createPortal } from 'react-dom';
7
8
 
8
- import { Capabilities, useAppGraph, useCapabilities } from '@dxos/app-framework';
9
- import { isInstanceOf } from '@dxos/echo-schema';
10
- import { fullyQualifiedId, getSpace } from '@dxos/react-client/echo';
9
+ import { Capabilities, Surface, useAppGraph, useCapabilities, usePluginManager } from '@dxos/app-framework';
10
+ import { DXN, Filter, Obj, Query } from '@dxos/echo';
11
+ import { SpaceCapabilities } from '@dxos/plugin-space';
12
+ import { fullyQualifiedId, getSpace, useQuery, useSpace } from '@dxos/react-client/echo';
13
+ import { toLocalizedString, useTranslation } from '@dxos/react-ui';
11
14
  import { type SelectionManager } from '@dxos/react-ui-attention';
15
+ import {
16
+ type CommandMenuGroup,
17
+ type CommandMenuItem,
18
+ insertAtCursor,
19
+ insertAtLineStart,
20
+ type PreviewLinkRef,
21
+ type PreviewOptions,
22
+ } from '@dxos/react-ui-editor';
12
23
  import { DataType } from '@dxos/schema';
13
24
 
14
25
  import { MarkdownEditor, type MarkdownEditorProps } from './MarkdownEditor';
@@ -26,7 +37,6 @@ export type MarkdownContainerProps = Pick<
26
37
  selectionManager?: SelectionManager;
27
38
  };
28
39
 
29
- // TODO(burdon): Factor out difference for ECHO and non-ECHO objects; i.e., single component.
30
40
  const MarkdownContainer = ({
31
41
  id,
32
42
  role,
@@ -37,58 +47,145 @@ const MarkdownContainer = ({
37
47
  editorStateStore,
38
48
  onViewModeChange,
39
49
  }: MarkdownContainerProps) => {
50
+ const { t } = useTranslation();
40
51
  const scrollPastEnd = role === 'article';
41
- const doc = isInstanceOf(DocumentType, object) ? object : undefined;
42
- const text = isInstanceOf(DataType.Text, object) ? object : undefined;
43
- const extensions = useExtensions({ document: doc, text, id, settings, selectionManager, viewMode, editorStateStore });
44
-
45
- if (doc) {
46
- return (
47
- <DocumentEditor
48
- id={fullyQualifiedId(object)}
49
- role={role}
50
- document={doc}
51
- extensions={extensions}
52
- viewMode={viewMode}
53
- settings={settings}
54
- scrollPastEnd={scrollPastEnd}
55
- onViewModeChange={onViewModeChange}
56
- />
57
- );
58
- } else if (text) {
59
- return (
60
- <MarkdownEditor
61
- id={id}
62
- role={role}
63
- initialValue={text.content}
64
- extensions={extensions}
65
- viewMode={viewMode}
66
- toolbar={settings.toolbar}
67
- inputMode={settings.editorInputMode}
68
- scrollPastEnd={scrollPastEnd}
69
- onViewModeChange={onViewModeChange}
70
- />
71
- );
72
- } else {
52
+ const doc = Obj.instanceOf(DocumentType, object) ? object : undefined;
53
+ const text = Obj.instanceOf(DataType.Text, object) ? object : undefined;
54
+ const [previewBlocks, setPreviewBlocks] = useState<{ link: PreviewLinkRef; el: HTMLElement }[]>([]);
55
+ const previewOptions = useMemo(
56
+ (): PreviewOptions => ({
57
+ addBlockContainer: (link, el) => {
58
+ setPreviewBlocks((prev) => [...prev, { link, el }]);
59
+ },
60
+ removeBlockContainer: (link) => {
61
+ setPreviewBlocks((prev) => prev.filter(({ link: prevLink }) => prevLink.ref !== link.ref));
62
+ },
63
+ }),
64
+ [],
65
+ );
66
+ const extensions = useExtensions({
67
+ document: doc,
68
+ text,
69
+ id,
70
+ settings,
71
+ selectionManager,
72
+ viewMode,
73
+ editorStateStore,
74
+ previewOptions,
75
+ });
76
+
77
+ // TODO(wittjosiah): Factor out.
78
+ const manager = usePluginManager();
79
+ const resolve = useCallback(
80
+ (typename: string) =>
81
+ manager.context.getCapabilities(Capabilities.Metadata).find(({ id }) => id === typename)?.metadata ?? {},
82
+ [manager],
83
+ );
84
+ const space = getSpace(object);
85
+ const objectForms = useCapabilities(SpaceCapabilities.ObjectForm);
86
+ const filter = useMemo(() => Filter.or(...objectForms.map((form) => Filter.type(form.objectSchema))), [objectForms]);
87
+ const onLinkQuery = useCallback(
88
+ async (query?: string): Promise<CommandMenuGroup[]> => {
89
+ const name = query?.startsWith('@') ? query.slice(1).toLowerCase() : query?.toLowerCase() ?? '';
90
+ const results = await space?.db.query(Query.select(filter)).run();
91
+ // TODO(wittjosiah): Use `Obj.Any` type.
92
+ const getLabel = (object: any) => {
93
+ const type = Obj.getTypename(object)!;
94
+ const metadata = resolve(type);
95
+ return (
96
+ metadata.label?.(object) || object.name || ['object name placeholder', { ns: type, default: 'New object' }]
97
+ );
98
+ };
99
+ const items =
100
+ results?.objects
101
+ .filter((object) => toLocalizedString(getLabel(object), t).toLowerCase().includes(name))
102
+ // TODO(wittjosiah): Remove `any` type.
103
+ .map((object: any): CommandMenuItem => {
104
+ const metadata = resolve(Obj.getTypename(object)!);
105
+ const label = toLocalizedString(getLabel(object), t);
106
+ return {
107
+ id: object.id,
108
+ label,
109
+ icon: metadata.icon,
110
+ onSelect: (view, head) => {
111
+ const link = `[${label}][${Obj.getDXN(object)}]`;
112
+ if (query?.startsWith('@')) {
113
+ insertAtLineStart(view, head, `!${link}\n`);
114
+ } else {
115
+ insertAtCursor(view, head, `${link} `);
116
+ }
117
+ },
118
+ };
119
+ }) ?? [];
120
+ return [{ id: 'echo', items }];
121
+ },
122
+ [filter, resolve, space],
123
+ );
124
+
125
+ const editor = doc ? (
126
+ <DocumentEditor
127
+ id={fullyQualifiedId(object)}
128
+ role={role}
129
+ document={doc}
130
+ extensions={extensions}
131
+ viewMode={viewMode}
132
+ settings={settings}
133
+ scrollPastEnd={scrollPastEnd}
134
+ onViewModeChange={onViewModeChange}
135
+ onLinkQuery={space ? onLinkQuery : undefined}
136
+ />
137
+ ) : text ? (
138
+ <MarkdownEditor
139
+ id={id}
140
+ role={role}
141
+ initialValue={text.content}
142
+ extensions={extensions}
143
+ viewMode={viewMode}
144
+ toolbar={settings.toolbar}
145
+ inputMode={settings.editorInputMode}
146
+ scrollPastEnd={scrollPastEnd}
147
+ onViewModeChange={onViewModeChange}
148
+ onLinkQuery={space ? onLinkQuery : undefined}
149
+ />
150
+ ) : (
73
151
  // TODO(burdon): Normalize with above.
74
- return (
75
- <MarkdownEditor
76
- id={id}
77
- role={role}
78
- initialValue={object.text}
79
- extensions={extensions}
80
- viewMode={viewMode}
81
- toolbar={settings.toolbar}
82
- inputMode={settings.editorInputMode}
83
- scrollPastEnd={scrollPastEnd}
84
- onViewModeChange={onViewModeChange}
85
- />
86
- );
87
- }
152
+ <MarkdownEditor
153
+ id={id}
154
+ role={role}
155
+ initialValue={object.text}
156
+ extensions={extensions}
157
+ viewMode={viewMode}
158
+ toolbar={settings.toolbar}
159
+ inputMode={settings.editorInputMode}
160
+ scrollPastEnd={scrollPastEnd}
161
+ onViewModeChange={onViewModeChange}
162
+ onLinkQuery={space ? onLinkQuery : undefined}
163
+ />
164
+ );
165
+
166
+ return (
167
+ <>
168
+ {editor}
169
+ {previewBlocks.map(({ link, el }) => (
170
+ <PreviewBlock key={link.ref} link={link} el={el} />
171
+ ))}
172
+ </>
173
+ );
174
+ };
175
+
176
+ // TODO(wittjosiah): This shouldn't be "card" but "block".
177
+ // It's not a preview card but an interactive embedded object.
178
+ const PreviewBlock = ({ link, el }: { link: PreviewLinkRef; el: HTMLElement }) => {
179
+ const echoDXN = useMemo(() => DXN.parse(link.ref).asEchoDXN(), [link.ref]);
180
+ const space = useSpace(echoDXN?.spaceId);
181
+ const [subject] = useQuery(space, Query.select(Filter.ids(echoDXN?.echoId ?? '')));
182
+ const data = useMemo(() => ({ subject }), [subject]);
183
+
184
+ return createPortal(<Surface role='card--document' data={data} limit={1} />, el);
88
185
  };
89
186
 
90
187
  type DocumentEditorProps = Omit<MarkdownContainerProps, 'object' | 'extensionProviders' | 'editorStateStore'> &
91
- Pick<MarkdownEditorProps, 'id' | 'scrollPastEnd' | 'extensions'> & {
188
+ Pick<MarkdownEditorProps, 'id' | 'scrollPastEnd' | 'extensions' | 'onLinkQuery'> & {
92
189
  document: DocumentType;
93
190
  };
94
191
 
@@ -25,7 +25,7 @@ type StoryProps = MarkdownEditorProps & {
25
25
  };
26
26
 
27
27
  const DefaultStory = ({ content = '# Test', toolbar }: StoryProps) => {
28
- const doc = useMemo(() => createObject({ content }), [content]);
28
+ const doc = useMemo(() => createObject({ content }), [content]); // TODO(burdon): Remove dependency on createObject.
29
29
  const extensions = useMemo(() => [automerge(createDocAccessor(doc, ['content']))], [doc]);
30
30
  return <MarkdownEditor id='test' initialValue={doc.content} extensions={extensions} toolbar={toolbar} />;
31
31
  };
@@ -3,33 +3,42 @@
3
3
  //
4
4
 
5
5
  import { type EditorView } from '@codemirror/view';
6
- import React, { useMemo, useEffect, useCallback } from 'react';
6
+ import React, { forwardRef, useMemo, useEffect, useCallback, useImperativeHandle, useRef } from 'react';
7
7
  import { useDropzone } from 'react-dropzone';
8
8
 
9
9
  import { type FileInfo } from '@dxos/app-framework';
10
10
  import { invariant } from '@dxos/invariant';
11
- import { useThemeContext, useTranslation } from '@dxos/react-ui';
11
+ import { toLocalizedString, useThemeContext, useTranslation } from '@dxos/react-ui';
12
12
  import {
13
+ CommandMenu,
14
+ type CommandMenuGroup,
13
15
  type DNDOptions,
14
- type EditorViewMode,
15
16
  type EditorInputMode,
16
17
  type EditorSelectionState,
17
18
  type EditorStateStore,
18
19
  EditorToolbar,
20
+ type EditorToolbarActionGraphProps,
21
+ type EditorViewMode,
22
+ RefPopover,
19
23
  type UseTextEditorProps,
24
+ addLink,
25
+ createElement,
26
+ coreSlashCommands,
20
27
  createBasicExtensions,
21
28
  createMarkdownExtensions,
22
29
  createThemeExtensions,
23
30
  dropFile,
24
- editorSlots,
25
31
  editorGutter,
32
+ editorSlots,
33
+ filterItems,
34
+ linkSlashCommands,
26
35
  processEditorPayload,
27
36
  stackItemContentEditorClassNames,
37
+ useEditorToolbarState,
28
38
  useFormattingState,
29
39
  useTextEditor,
30
- useEditorToolbarState,
31
- addLink,
32
- type EditorToolbarActionGraphProps,
40
+ useCommandMenu,
41
+ type UseCommandMenuOptions,
33
42
  } from '@dxos/react-ui-editor';
34
43
  import { StackItem } from '@dxos/react-ui-stack';
35
44
  import { isNotFalsy, isNonNullable } from '@dxos/util';
@@ -43,168 +52,235 @@ export type MarkdownEditorProps = {
43
52
  role?: string;
44
53
  inputMode?: EditorInputMode;
45
54
  scrollPastEnd?: boolean;
55
+ slashCommandGroups?: CommandMenuGroup[];
46
56
  toolbar?: boolean;
47
57
  customActions?: EditorToolbarActionGraphProps['customActions'];
48
58
  // TODO(wittjosiah): Generalize custom toolbar actions (e.g. comment, upload, etc.)
49
59
  viewMode?: EditorViewMode;
50
60
  editorStateStore?: EditorStateStore;
51
61
  onViewModeChange?: (id: string, mode: EditorViewMode) => void;
62
+ onLinkQuery?: (query?: string) => Promise<CommandMenuGroup[]>;
52
63
  onFileUpload?: (file: File) => Promise<FileInfo | undefined>;
53
64
  } & Pick<UseTextEditorProps, 'initialValue' | 'extensions'> &
54
65
  Partial<Pick<MarkdownPluginState, 'extensionProviders'>>;
55
66
 
56
67
  /**
57
68
  * Base markdown editor component.
58
- *
59
69
  * This component provides all the features of the markdown editor that do no depend on ECHO.
60
70
  * This allows it to be used as a common editor for markdown content on arbitrary backends (e.g. files).
61
71
  */
62
72
  export const MarkdownEditor = ({
63
- id,
64
- role = 'article',
65
- initialValue,
66
- extensions,
67
- extensionProviders,
68
- scrollPastEnd,
69
- toolbar,
70
- customActions,
71
- viewMode,
72
- editorStateStore,
73
- onFileUpload,
74
- onViewModeChange,
73
+ extensions: _extensions,
74
+ slashCommandGroups,
75
+ onLinkQuery,
76
+ ...props
75
77
  }: MarkdownEditorProps) => {
76
- const { t } = useTranslation(MARKDOWN_PLUGIN);
77
- const { themeMode } = useThemeContext();
78
- const toolbarState = useEditorToolbarState({ viewMode });
79
- const formattingObserver = useFormattingState(toolbarState);
80
-
81
- // Restore last selection and scroll point.
82
- const { scrollTo, selection } = useMemo<EditorSelectionState>(() => editorStateStore?.getState(id) ?? {}, [id]);
83
-
84
- // Extensions from other plugins.
85
- // TODO(burdon): Reconcile with DocumentEditor.useExtensions.
86
- const providerExtensions = useMemo(
87
- () => extensionProviders?.flatMap((provider) => provider({})).filter(isNonNullable),
88
- [extensionProviders],
78
+ const { t } = useTranslation();
79
+ const viewRef = useRef<EditorView>();
80
+
81
+ const getMenu = useCallback(
82
+ (trigger: string, query?: string) => {
83
+ switch (trigger) {
84
+ case '@':
85
+ return onLinkQuery?.(query) ?? [];
86
+ case '/':
87
+ default:
88
+ return filterItems([coreSlashCommands, linkSlashCommands, ...(slashCommandGroups ?? [])], (item) =>
89
+ query ? toLocalizedString(item.label, t).toLowerCase().includes(query.toLowerCase()) : true,
90
+ );
91
+ }
92
+ },
93
+ [onLinkQuery, slashCommandGroups],
89
94
  );
90
95
 
91
- // TODO(wittjosiah): Factor out to file uploader plugin.
92
- // Drag files.
93
- const handleDrop: DNDOptions['onDrop'] = async (view, { files }) => {
94
- const file = files[0];
95
- const info = file && onFileUpload ? await onFileUpload(file) : undefined;
96
- if (info) {
97
- processEditorPayload(view, { type: 'image', data: info.url });
98
- }
99
- };
100
-
101
- const {
102
- parentRef,
103
- view: editorView,
104
- focusAttributes,
105
- } = useTextEditor(
106
- () => ({
96
+ const options = useMemo<UseCommandMenuOptions>(() => {
97
+ const trigger = onLinkQuery ? ['/', '@'] : ['/'];
98
+ return {
99
+ viewRef,
100
+ trigger,
101
+ placeholder: {
102
+ delay: 3_000,
103
+ content: () => {
104
+ return createElement('div', undefined, [
105
+ createElement('span', { text: 'Press' }),
106
+ ...trigger.map((text) =>
107
+ createElement('span', {
108
+ className: 'border border-separator rounded-sm mx-1 px-1.5 pt-[1px] pb-[2px]',
109
+ text,
110
+ }),
111
+ ),
112
+ createElement('span', { text: 'for commands.' }),
113
+ ]);
114
+ },
115
+ },
116
+ getMenu,
117
+ };
118
+ }, [getMenu]);
119
+
120
+ const { commandMenu, groupsRef, currentItem, onSelect, ...refPopoverProps } = useCommandMenu(options);
121
+
122
+ const extensions = useMemo(() => [_extensions, commandMenu].filter(isNotFalsy), [_extensions, commandMenu]);
123
+
124
+ return (
125
+ <RefPopover modal={false} {...refPopoverProps}>
126
+ <MarkdownEditorImpl ref={viewRef} {...props} extensions={extensions} />
127
+ <CommandMenu groups={groupsRef.current} currentItem={currentItem} onSelect={onSelect} />
128
+ </RefPopover>
129
+ );
130
+ };
131
+
132
+ const MarkdownEditorImpl = forwardRef<EditorView | undefined, MarkdownEditorProps>(
133
+ (
134
+ {
135
+ id,
136
+ role = 'article',
107
137
  initialValue,
108
- extensions: [
109
- formattingObserver,
110
- createBasicExtensions({
111
- readOnly: viewMode === 'readonly',
112
- placeholder: t('editor placeholder'),
113
- scrollPastEnd: role === 'section' ? false : scrollPastEnd,
138
+ customActions,
139
+ editorStateStore,
140
+ extensions,
141
+ extensionProviders,
142
+ scrollPastEnd,
143
+ toolbar,
144
+ viewMode,
145
+ onFileUpload,
146
+ onViewModeChange,
147
+ },
148
+ forwardedRef,
149
+ ) => {
150
+ const { t } = useTranslation(MARKDOWN_PLUGIN);
151
+ const { themeMode } = useThemeContext();
152
+ const toolbarState = useEditorToolbarState({ viewMode });
153
+ const formattingObserver = useFormattingState(toolbarState);
154
+
155
+ // Restore last selection and scroll point.
156
+ const { scrollTo, selection } = useMemo<EditorSelectionState>(() => editorStateStore?.getState(id) ?? {}, [id]);
157
+
158
+ // Extensions from other plugins.
159
+ // TODO(burdon): Reconcile with DocumentEditor.useExtensions.
160
+ const providerExtensions = useMemo(
161
+ () => extensionProviders?.flatMap((provider) => provider({})).filter(isNonNullable),
162
+ [extensionProviders],
163
+ );
164
+
165
+ // TODO(wittjosiah): Factor out to file uploader plugin.
166
+ // Drag files.
167
+ const handleDrop: DNDOptions['onDrop'] = async (view, { files }) => {
168
+ const file = files[0];
169
+ const info = file && onFileUpload ? await onFileUpload(file) : undefined;
170
+ if (info) {
171
+ processEditorPayload(view, { type: 'image', data: info.url });
172
+ }
173
+ };
174
+
175
+ const {
176
+ parentRef,
177
+ view: editorView,
178
+ focusAttributes,
179
+ } = useTextEditor(
180
+ () => ({
181
+ initialValue,
182
+ extensions: [
183
+ formattingObserver,
184
+ createBasicExtensions({
185
+ readOnly: viewMode === 'readonly',
186
+ placeholder: t('editor placeholder'),
187
+ scrollPastEnd: role === 'section' ? false : scrollPastEnd,
188
+ }),
189
+ createMarkdownExtensions({ themeMode }),
190
+ createThemeExtensions({ themeMode, syntaxHighlighting: true, slots: editorSlots }),
191
+ editorGutter,
192
+ role !== 'section' && onFileUpload && dropFile({ onDrop: handleDrop }),
193
+ providerExtensions,
194
+ extensions,
195
+ ].filter(isNotFalsy),
196
+ ...(role !== 'section' && {
197
+ id,
198
+ scrollTo,
199
+ selection,
200
+ // TODO(wittjosiah): Autofocus based on layout is racy.
201
+ // autoFocus: layoutPlugin?.provides.layout ? layoutPlugin?.provides.layout.scrollIntoView === id : true,
202
+ moveToEndOfLine: true,
114
203
  }),
115
- createMarkdownExtensions({ themeMode }),
116
- createThemeExtensions({ themeMode, syntaxHighlighting: true, slots: editorSlots }),
117
- editorGutter,
118
- role !== 'section' && onFileUpload && dropFile({ onDrop: handleDrop }),
119
- providerExtensions,
120
- extensions,
121
- ].filter(isNotFalsy),
122
- ...(role !== 'section' && {
123
- id,
124
- scrollTo,
125
- selection,
126
- // TODO(wittjosiah): Autofocus based on layout is racy.
127
- // autoFocus: layoutPlugin?.provides.layout ? layoutPlugin?.provides.layout.scrollIntoView === id : true,
128
- moveToEndOfLine: true,
129
204
  }),
130
- }),
131
- [id, formattingObserver, viewMode, themeMode, extensions, providerExtensions],
132
- );
205
+ [id, formattingObserver, viewMode, themeMode, extensions, providerExtensions],
206
+ );
133
207
 
134
- useTest(editorView);
135
- useSelectCurrentThread(editorView, id);
208
+ useImperativeHandle(forwardedRef, () => editorView, [editorView]);
209
+ useTest(editorView);
210
+ useSelectCurrentThread(editorView, id);
136
211
 
137
- // https://react-dropzone.js.org/#src
138
- const { acceptedFiles, getInputProps, open } = useDropzone({
139
- multiple: false,
140
- noDrag: true,
141
- accept: {
142
- 'image/*': ['.jpg', '.jpeg', '.png', '.gif'],
143
- },
144
- });
212
+ // https://react-dropzone.js.org/#src
213
+ const { acceptedFiles, getInputProps, open } = useDropzone({
214
+ multiple: false,
215
+ noDrag: true,
216
+ accept: {
217
+ 'image/*': ['.jpg', '.jpeg', '.png', '.gif'],
218
+ },
219
+ });
145
220
 
146
- useEffect(() => {
147
- if (editorView && onFileUpload && acceptedFiles.length) {
148
- requestAnimationFrame(async () => {
149
- // NOTE: Clone file since react-dropzone patches in a non-standard `path` property, which confuses IPFS.
150
- const f = acceptedFiles[0];
151
- const file = new File([f], f.name, {
152
- type: f.type,
153
- lastModified: f.lastModified,
221
+ useEffect(() => {
222
+ if (editorView && onFileUpload && acceptedFiles.length) {
223
+ requestAnimationFrame(async () => {
224
+ // NOTE: Clone file since react-dropzone patches in a non-standard `path` property, which confuses IPFS.
225
+ const f = acceptedFiles[0];
226
+ const file = new File([f], f.name, {
227
+ type: f.type,
228
+ lastModified: f.lastModified,
229
+ });
230
+
231
+ const info = await onFileUpload(file);
232
+ if (info) {
233
+ addLink({ url: info.url, image: true })(editorView);
234
+ }
154
235
  });
236
+ }
237
+ }, [acceptedFiles, editorView, onFileUpload]);
155
238
 
156
- const info = await onFileUpload(file);
157
- if (info) {
158
- addLink({ url: info.url, image: true })(editorView);
159
- }
160
- });
161
- }
162
- }, [acceptedFiles, editorView, onFileUpload]);
239
+ const getView = useCallback(() => {
240
+ invariant(editorView);
241
+ return editorView;
242
+ }, [editorView]);
163
243
 
164
- const getView = useCallback(() => {
165
- invariant(editorView);
166
- return editorView;
167
- }, [editorView]);
244
+ const handleViewModeChange = useCallback(
245
+ (mode: EditorViewMode) => onViewModeChange?.(id, mode),
246
+ [id, onViewModeChange],
247
+ );
168
248
 
169
- const handleViewModeChange = useCallback(
170
- (mode: EditorViewMode) => onViewModeChange?.(id, mode),
171
- [id, onViewModeChange],
172
- );
249
+ const handleImageUpload = useCallback(() => {
250
+ if (onFileUpload) {
251
+ open();
252
+ }
253
+ }, [onFileUpload]);
173
254
 
174
- const handleImageUpload = useCallback(() => {
175
- if (onFileUpload) {
176
- open();
177
- }
178
- }, [onFileUpload]);
179
-
180
- return (
181
- <StackItem.Content toolbar={!!toolbar}>
182
- {toolbar && (
183
- <>
184
- <EditorToolbar
185
- attendableId={id}
186
- role={role}
187
- state={toolbarState}
188
- customActions={customActions}
189
- getView={getView}
190
- image={handleImageUpload}
191
- viewMode={handleViewModeChange}
192
- />
193
- <input {...getInputProps()} />
194
- </>
195
- )}
196
- <div
197
- role='none'
198
- ref={parentRef}
199
- data-testid='composer.markdownRoot'
200
- data-toolbar={toolbar ? 'enabled' : 'disabled'}
201
- className={stackItemContentEditorClassNames(role)}
202
- data-popover-collision-boundary={true}
203
- {...focusAttributes}
204
- />
205
- </StackItem.Content>
206
- );
207
- };
255
+ return (
256
+ <StackItem.Content toolbar={!!toolbar}>
257
+ {toolbar && (
258
+ <>
259
+ <EditorToolbar
260
+ attendableId={id}
261
+ role={role}
262
+ state={toolbarState}
263
+ customActions={customActions}
264
+ getView={getView}
265
+ image={handleImageUpload}
266
+ viewMode={handleViewModeChange}
267
+ />
268
+ <input {...getInputProps()} />
269
+ </>
270
+ )}
271
+ <div
272
+ role='none'
273
+ ref={parentRef}
274
+ data-testid='composer.markdownRoot'
275
+ data-toolbar={toolbar ? 'enabled' : 'disabled'}
276
+ className={stackItemContentEditorClassNames(role)}
277
+ data-popover-collision-boundary={true}
278
+ {...focusAttributes}
279
+ />
280
+ </StackItem.Content>
281
+ );
282
+ },
283
+ );
208
284
 
209
285
  // Expose editor view for playwright tests.
210
286
  // TODO(wittjosiah): Find a better way to expose this or find a way to limit it to test runs.