@dxos/plugin-file 0.0.0

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 (187) hide show
  1. package/LICENSE +105 -0
  2. package/PLUGIN.mdl +372 -0
  3. package/dist/lib/neutral/FileArticle-YU6NN3KN.mjs +80 -0
  4. package/dist/lib/neutral/FileArticle-YU6NN3KN.mjs.map +7 -0
  5. package/dist/lib/neutral/FilePlugin.mjs +58 -0
  6. package/dist/lib/neutral/FilePlugin.mjs.map +7 -0
  7. package/dist/lib/neutral/FilePlugin.node.mjs +23 -0
  8. package/dist/lib/neutral/FilePlugin.node.mjs.map +7 -0
  9. package/dist/lib/neutral/capabilities/index.mjs +23 -0
  10. package/dist/lib/neutral/capabilities/index.mjs.map +7 -0
  11. package/dist/lib/neutral/chunk-DKGTSUUH.mjs +8 -0
  12. package/dist/lib/neutral/chunk-DKGTSUUH.mjs.map +7 -0
  13. package/dist/lib/neutral/chunk-GMYSEHV4.mjs +40 -0
  14. package/dist/lib/neutral/chunk-GMYSEHV4.mjs.map +7 -0
  15. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  16. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  17. package/dist/lib/neutral/chunk-LBZ4MCLI.mjs +45 -0
  18. package/dist/lib/neutral/chunk-LBZ4MCLI.mjs.map +7 -0
  19. package/dist/lib/neutral/chunk-YODPX62X.mjs +136 -0
  20. package/dist/lib/neutral/chunk-YODPX62X.mjs.map +7 -0
  21. package/dist/lib/neutral/chunk-ZUXUTRAJ.mjs +80 -0
  22. package/dist/lib/neutral/chunk-ZUXUTRAJ.mjs.map +7 -0
  23. package/dist/lib/neutral/components/index.mjs +74 -0
  24. package/dist/lib/neutral/components/index.mjs.map +7 -0
  25. package/dist/lib/neutral/containers/index.mjs +59 -0
  26. package/dist/lib/neutral/containers/index.mjs.map +7 -0
  27. package/dist/lib/neutral/create-IUUG7RI6.mjs +17 -0
  28. package/dist/lib/neutral/create-IUUG7RI6.mjs.map +7 -0
  29. package/dist/lib/neutral/create-object-V667G4ZL.mjs +30 -0
  30. package/dist/lib/neutral/create-object-V667G4ZL.mjs.map +7 -0
  31. package/dist/lib/neutral/file-uploader-BCJH6TUL.mjs +41 -0
  32. package/dist/lib/neutral/file-uploader-BCJH6TUL.mjs.map +7 -0
  33. package/dist/lib/neutral/index.mjs +32 -0
  34. package/dist/lib/neutral/index.mjs.map +7 -0
  35. package/dist/lib/neutral/inline-backend-FGGGDYRB.mjs +37 -0
  36. package/dist/lib/neutral/inline-backend-FGGGDYRB.mjs.map +7 -0
  37. package/dist/lib/neutral/markdown-extension-RA5YNMTW.mjs +230 -0
  38. package/dist/lib/neutral/markdown-extension-RA5YNMTW.mjs.map +7 -0
  39. package/dist/lib/neutral/meta.json +1 -0
  40. package/dist/lib/neutral/meta.mjs +8 -0
  41. package/dist/lib/neutral/meta.mjs.map +7 -0
  42. package/dist/lib/neutral/operation-handler-CPDANWGH.mjs +13 -0
  43. package/dist/lib/neutral/operation-handler-CPDANWGH.mjs.map +7 -0
  44. package/dist/lib/neutral/operations/index.mjs +8 -0
  45. package/dist/lib/neutral/operations/index.mjs.map +7 -0
  46. package/dist/lib/neutral/plugin.mjs +16 -0
  47. package/dist/lib/neutral/plugin.mjs.map +7 -0
  48. package/dist/lib/neutral/react-surface-5BXKSHD2.mjs +60 -0
  49. package/dist/lib/neutral/react-surface-5BXKSHD2.mjs.map +7 -0
  50. package/dist/lib/neutral/read-HG6CSWKR.mjs +28 -0
  51. package/dist/lib/neutral/read-HG6CSWKR.mjs.map +7 -0
  52. package/dist/lib/neutral/settings-RR3RESL2.mjs +28 -0
  53. package/dist/lib/neutral/settings-RR3RESL2.mjs.map +7 -0
  54. package/dist/lib/neutral/skill-definition-YHBGWBUS.mjs +15 -0
  55. package/dist/lib/neutral/skill-definition-YHBGWBUS.mjs.map +7 -0
  56. package/dist/lib/neutral/skills/index.mjs +11 -0
  57. package/dist/lib/neutral/skills/index.mjs.map +7 -0
  58. package/dist/lib/neutral/translations.mjs +36 -0
  59. package/dist/lib/neutral/translations.mjs.map +7 -0
  60. package/dist/lib/neutral/types/index.mjs +22 -0
  61. package/dist/lib/neutral/types/index.mjs.map +7 -0
  62. package/dist/types/dx.config.d.ts +28 -0
  63. package/dist/types/dx.config.d.ts.map +1 -0
  64. package/dist/types/src/FilePlugin.d.ts +4 -0
  65. package/dist/types/src/FilePlugin.d.ts.map +1 -0
  66. package/dist/types/src/FilePlugin.node.d.ts +4 -0
  67. package/dist/types/src/FilePlugin.node.d.ts.map +1 -0
  68. package/dist/types/src/FilePlugin.test.d.ts +2 -0
  69. package/dist/types/src/FilePlugin.test.d.ts.map +1 -0
  70. package/dist/types/src/capabilities/create-object.d.ts +5 -0
  71. package/dist/types/src/capabilities/create-object.d.ts.map +1 -0
  72. package/dist/types/src/capabilities/file-uploader.d.ts +5 -0
  73. package/dist/types/src/capabilities/file-uploader.d.ts.map +1 -0
  74. package/dist/types/src/capabilities/index.d.ts +11 -0
  75. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  76. package/dist/types/src/capabilities/inline-backend.d.ts +11 -0
  77. package/dist/types/src/capabilities/inline-backend.d.ts.map +1 -0
  78. package/dist/types/src/capabilities/markdown-extension.d.ts +5 -0
  79. package/dist/types/src/capabilities/markdown-extension.d.ts.map +1 -0
  80. package/dist/types/src/capabilities/operation-handler.d.ts +6 -0
  81. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  82. package/dist/types/src/capabilities/react-surface.d.ts +5 -0
  83. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  84. package/dist/types/src/capabilities/settings.d.ts +5 -0
  85. package/dist/types/src/capabilities/settings.d.ts.map +1 -0
  86. package/dist/types/src/capabilities/skill-definition.d.ts +5 -0
  87. package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -0
  88. package/dist/types/src/components/FileInput/FileInput.d.ts +8 -0
  89. package/dist/types/src/components/FileInput/FileInput.d.ts.map +1 -0
  90. package/dist/types/src/components/FileInput/index.d.ts +2 -0
  91. package/dist/types/src/components/FileInput/index.d.ts.map +1 -0
  92. package/dist/types/src/components/FilePreview/FilePreview.d.ts +12 -0
  93. package/dist/types/src/components/FilePreview/FilePreview.d.ts.map +1 -0
  94. package/dist/types/src/components/FilePreview/index.d.ts +2 -0
  95. package/dist/types/src/components/FilePreview/index.d.ts.map +1 -0
  96. package/dist/types/src/components/index.d.ts +3 -0
  97. package/dist/types/src/components/index.d.ts.map +1 -0
  98. package/dist/types/src/containers/FileArticle/FileArticle.d.ts +7 -0
  99. package/dist/types/src/containers/FileArticle/FileArticle.d.ts.map +1 -0
  100. package/dist/types/src/containers/FileArticle/index.d.ts +2 -0
  101. package/dist/types/src/containers/FileArticle/index.d.ts.map +1 -0
  102. package/dist/types/src/containers/FileSettings/FileSettings.d.ts +6 -0
  103. package/dist/types/src/containers/FileSettings/FileSettings.d.ts.map +1 -0
  104. package/dist/types/src/containers/FileSettings/index.d.ts +2 -0
  105. package/dist/types/src/containers/FileSettings/index.d.ts.map +1 -0
  106. package/dist/types/src/containers/index.d.ts +4 -0
  107. package/dist/types/src/containers/index.d.ts.map +1 -0
  108. package/dist/types/src/extensions/image.d.ts +12 -0
  109. package/dist/types/src/extensions/image.d.ts.map +1 -0
  110. package/dist/types/src/extensions/index.d.ts +2 -0
  111. package/dist/types/src/extensions/index.d.ts.map +1 -0
  112. package/dist/types/src/index.d.ts +4 -0
  113. package/dist/types/src/index.d.ts.map +1 -0
  114. package/dist/types/src/meta.d.ts +33 -0
  115. package/dist/types/src/meta.d.ts.map +1 -0
  116. package/dist/types/src/operations/create.d.ts +25 -0
  117. package/dist/types/src/operations/create.d.ts.map +1 -0
  118. package/dist/types/src/operations/create.test.d.ts +2 -0
  119. package/dist/types/src/operations/create.test.d.ts.map +1 -0
  120. package/dist/types/src/operations/index.d.ts +3 -0
  121. package/dist/types/src/operations/index.d.ts.map +1 -0
  122. package/dist/types/src/operations/read.d.ts +5 -0
  123. package/dist/types/src/operations/read.d.ts.map +1 -0
  124. package/dist/types/src/plugin.d.ts +4 -0
  125. package/dist/types/src/plugin.d.ts.map +1 -0
  126. package/dist/types/src/skills/file-skill.d.ts +5 -0
  127. package/dist/types/src/skills/file-skill.d.ts.map +1 -0
  128. package/dist/types/src/skills/index.d.ts +2 -0
  129. package/dist/types/src/skills/index.d.ts.map +1 -0
  130. package/dist/types/src/translations.d.ts +40 -0
  131. package/dist/types/src/translations.d.ts.map +1 -0
  132. package/dist/types/src/types/FileCapabilities.d.ts +41 -0
  133. package/dist/types/src/types/FileCapabilities.d.ts.map +1 -0
  134. package/dist/types/src/types/FileOperation.d.ts +42 -0
  135. package/dist/types/src/types/FileOperation.d.ts.map +1 -0
  136. package/dist/types/src/types/Settings.d.ts +8 -0
  137. package/dist/types/src/types/Settings.d.ts.map +1 -0
  138. package/dist/types/src/types/index.d.ts +7 -0
  139. package/dist/types/src/types/index.d.ts.map +1 -0
  140. package/dist/types/src/types/limits.d.ts +4 -0
  141. package/dist/types/src/types/limits.d.ts.map +1 -0
  142. package/dist/types/src/types/types.d.ts +9 -0
  143. package/dist/types/src/types/types.d.ts.map +1 -0
  144. package/dist/types/tsconfig.tsbuildinfo +1 -0
  145. package/dx.config.ts +35 -0
  146. package/package.json +139 -0
  147. package/src/FilePlugin.node.ts +19 -0
  148. package/src/FilePlugin.test.ts +26 -0
  149. package/src/FilePlugin.tsx +56 -0
  150. package/src/capabilities/create-object.ts +30 -0
  151. package/src/capabilities/file-uploader.ts +35 -0
  152. package/src/capabilities/index.ts +18 -0
  153. package/src/capabilities/inline-backend.ts +41 -0
  154. package/src/capabilities/markdown-extension.ts +38 -0
  155. package/src/capabilities/operation-handler.ts +16 -0
  156. package/src/capabilities/react-surface.tsx +61 -0
  157. package/src/capabilities/settings.ts +31 -0
  158. package/src/capabilities/skill-definition.ts +14 -0
  159. package/src/components/FileInput/FileInput.tsx +48 -0
  160. package/src/components/FileInput/index.ts +5 -0
  161. package/src/components/FilePreview/FilePreview.tsx +55 -0
  162. package/src/components/FilePreview/index.ts +5 -0
  163. package/src/components/index.ts +6 -0
  164. package/src/containers/FileArticle/FileArticle.tsx +84 -0
  165. package/src/containers/FileArticle/index.ts +5 -0
  166. package/src/containers/FileSettings/FileSettings.tsx +67 -0
  167. package/src/containers/FileSettings/index.ts +5 -0
  168. package/src/containers/index.ts +9 -0
  169. package/src/extensions/image.tsx +232 -0
  170. package/src/extensions/index.ts +5 -0
  171. package/src/index.ts +7 -0
  172. package/src/meta.ts +9 -0
  173. package/src/operations/create.test.ts +53 -0
  174. package/src/operations/create.ts +87 -0
  175. package/src/operations/index.ts +10 -0
  176. package/src/operations/read.ts +35 -0
  177. package/src/plugin.ts +11 -0
  178. package/src/skills/file-skill.ts +38 -0
  179. package/src/skills/index.ts +5 -0
  180. package/src/translations.ts +37 -0
  181. package/src/types/FileCapabilities.ts +58 -0
  182. package/src/types/FileOperation.ts +44 -0
  183. package/src/types/Settings.ts +23 -0
  184. package/src/types/index.ts +12 -0
  185. package/src/types/limits.ts +21 -0
  186. package/src/types/types.ts +21 -0
  187. package/src/vite-env.d.ts +10 -0
@@ -0,0 +1,55 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { ForwardedRef } from 'react';
6
+
7
+ import { composable, composableProps } from '@dxos/react-ui';
8
+
9
+ export type FilePreviewProps = { type: string; url: string };
10
+
11
+ /**
12
+ * File/content preview — image, video, PDF (iframe), or fallback download link.
13
+ */
14
+ export const FilePreview = composable<HTMLElement, FilePreviewProps>(
15
+ ({ type, url, children, ...props }, forwardedRef) => {
16
+ if (type.startsWith('image/')) {
17
+ return (
18
+ <img
19
+ {...composableProps(props, { classNames: 'h-full w-full object-contain' })}
20
+ src={url}
21
+ ref={forwardedRef as ForwardedRef<HTMLImageElement>}
22
+ />
23
+ );
24
+ } else if (type.startsWith('video/')) {
25
+ return (
26
+ <video
27
+ {...composableProps(props, { classNames: 'h-full w-full object-contain' })}
28
+ src={url}
29
+ controls
30
+ ref={forwardedRef as ForwardedRef<HTMLVideoElement>}
31
+ />
32
+ );
33
+ } else if (type === 'application/pdf') {
34
+ return (
35
+ <iframe
36
+ {...composableProps(props, { classNames: 'h-full w-full overflow-auto' })}
37
+ src={url}
38
+ title='PDF preview'
39
+ ref={forwardedRef as ForwardedRef<HTMLIFrameElement>}
40
+ />
41
+ );
42
+ } else {
43
+ return (
44
+ <a
45
+ {...composableProps(props, { classNames: 'p-4 underline' })}
46
+ href={url}
47
+ download
48
+ ref={forwardedRef as ForwardedRef<HTMLAnchorElement>}
49
+ >
50
+ Download file
51
+ </a>
52
+ );
53
+ }
54
+ },
55
+ );
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './FilePreview';
@@ -0,0 +1,6 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './FileInput';
6
+ export * from './FilePreview';
@@ -0,0 +1,84 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useState } from 'react';
6
+
7
+ import { useCapabilities } from '@dxos/app-framework/ui';
8
+ import { type AppSurface } from '@dxos/app-toolkit/ui';
9
+ import { getSpace } from '@dxos/react-client/echo';
10
+ import { Panel } from '@dxos/react-ui';
11
+
12
+ import { FilePreview } from '#components';
13
+ import { File, FileCapabilities } from '#types';
14
+
15
+ export type FileArticleProps = AppSurface.ObjectArticleProps<File.File>;
16
+
17
+ export const FileArticle = ({ role, subject: file }: FileArticleProps) => {
18
+ const resolvers = useCapabilities(FileCapabilities.UrlResolver);
19
+ const [renderUrl, setRenderUrl] = useState<string | undefined>(undefined);
20
+
21
+ useEffect(() => {
22
+ const { data } = file;
23
+ if (data._tag === 'inline') {
24
+ const url = URL.createObjectURL(new Blob([data.bytes as BlobPart], { type: file.type }));
25
+ setRenderUrl(url);
26
+ return () => URL.revokeObjectURL(url);
27
+ }
28
+
29
+ // External URL: pass through `http(s):`/`data:`/`blob:` directly; otherwise dispatch to a resolver.
30
+ let cancelled = false;
31
+ let createdBlobUrl: string | undefined;
32
+
33
+ if (/^(?:https?|data|blob):/i.test(data.url)) {
34
+ setRenderUrl(data.url);
35
+ } else {
36
+ const resolver = resolvers.find((r) => r.test(data.url));
37
+ if (!resolver) {
38
+ setRenderUrl(undefined);
39
+ return;
40
+ }
41
+ setRenderUrl(undefined);
42
+ void resolver
43
+ .resolve(data.url, file, getSpace(file))
44
+ .then((url) => {
45
+ if (cancelled) {
46
+ if (url?.startsWith('blob:')) {
47
+ URL.revokeObjectURL(url);
48
+ }
49
+ return;
50
+ }
51
+ if (url?.startsWith('blob:')) {
52
+ createdBlobUrl = url;
53
+ }
54
+ setRenderUrl(url);
55
+ })
56
+ .catch(() => {
57
+ if (!cancelled) {
58
+ setRenderUrl(undefined);
59
+ }
60
+ });
61
+ }
62
+
63
+ return () => {
64
+ cancelled = true;
65
+ if (createdBlobUrl) {
66
+ URL.revokeObjectURL(createdBlobUrl);
67
+ }
68
+ };
69
+ }, [file, file.data, file.type, resolvers]);
70
+
71
+ if (!renderUrl) {
72
+ return null;
73
+ }
74
+
75
+ return (
76
+ <Panel.Root role={role} className='dx-document'>
77
+ <Panel.Content asChild>
78
+ <FilePreview type={file.type} url={renderUrl} />
79
+ </Panel.Content>
80
+ </Panel.Root>
81
+ );
82
+ };
83
+
84
+ export default FileArticle;
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export { FileArticle as default } from './FileArticle';
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback } from 'react';
6
+
7
+ import { useCapabilities } from '@dxos/app-framework/ui';
8
+ import { type AppSurface } from '@dxos/app-toolkit/ui';
9
+ import { Select, useTranslation } from '@dxos/react-ui';
10
+ import { Form } from '@dxos/react-ui-form';
11
+
12
+ import { meta } from '#meta';
13
+ import { FileCapabilities, Settings } from '#types';
14
+
15
+ export type FileSettingsProps = AppSurface.SettingsProps<Settings.Settings>;
16
+
17
+ export const FileSettings = ({ settings, onSettingsChange }: FileSettingsProps) => {
18
+ const { t } = useTranslation(meta.profile.key);
19
+ const backends = useCapabilities(FileCapabilities.Backend);
20
+ const requestedId = settings.backend ?? Settings.DEFAULT_BACKEND_ID;
21
+ const active =
22
+ backends.find((b) => b.id === requestedId) ?? backends.find((b) => b.id === Settings.DEFAULT_BACKEND_ID);
23
+ // Use the resolved backend id so the Select never shows a missing/stale value.
24
+ const activeId = active?.id ?? Settings.DEFAULT_BACKEND_ID;
25
+
26
+ const handleChange = useCallback(
27
+ (value: string) => onSettingsChange?.((current) => ({ ...current, backend: value })),
28
+ [onSettingsChange],
29
+ );
30
+
31
+ return (
32
+ <Form.Root
33
+ schema={Settings.Settings}
34
+ values={settings}
35
+ variant='settings'
36
+ readonly={!onSettingsChange}
37
+ onValuesChanged={(values) => onSettingsChange?.((current) => ({ ...current, ...values }))}
38
+ >
39
+ <Form.Viewport scroll>
40
+ <Form.Content>
41
+ <Form.Section title={meta.profile.name ?? meta.profile.key}>
42
+ <Form.Row
43
+ label={t('settings.backend.label')}
44
+ description={active?.description ?? t('settings.backend.description')}
45
+ >
46
+ <Select.Root value={activeId} onValueChange={handleChange} disabled={!onSettingsChange}>
47
+ <Select.TriggerButton placeholder={t('settings.backend.placeholder')} />
48
+ <Select.Portal>
49
+ <Select.Content>
50
+ <Select.Viewport>
51
+ {backends.map((backend) => (
52
+ <Select.Option key={backend.id} value={backend.id}>
53
+ {backend.name}
54
+ </Select.Option>
55
+ ))}
56
+ </Select.Viewport>
57
+ <Select.Arrow />
58
+ </Select.Content>
59
+ </Select.Portal>
60
+ </Select.Root>
61
+ </Form.Row>
62
+ </Form.Section>
63
+ </Form.Content>
64
+ </Form.Viewport>
65
+ </Form.Root>
66
+ );
67
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './FileSettings';
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type ComponentType, lazy } from 'react';
6
+
7
+ export const FileArticle: ComponentType<any> = lazy(() => import('./FileArticle'));
8
+
9
+ export * from './FileSettings';
@@ -0,0 +1,232 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { syntaxTree } from '@codemirror/language';
6
+ import { type EditorState, type Extension, type Range, StateField } from '@codemirror/state';
7
+ import { Decoration, EditorView, WidgetType } from '@codemirror/view';
8
+ import React from 'react';
9
+ import { createRoot } from 'react-dom/client';
10
+
11
+ import { Filter, Obj } from '@dxos/echo';
12
+ import { EID } from '@dxos/keys';
13
+ import { type Space } from '@dxos/react-client/echo';
14
+ import { Status, ThemeProvider } from '@dxos/react-ui';
15
+ import { defaultTx } from '@dxos/react-ui';
16
+ import { focusField } from '@dxos/ui-editor';
17
+ import { type MaybePromise } from '@dxos/util';
18
+
19
+ import { File, FileCapabilities } from '#types';
20
+
21
+ const WAIT_UNTIL_LOADER = 1500;
22
+
23
+ export type ImageOptions = {
24
+ space: Space;
25
+ resolvers: readonly FileCapabilities.UrlResolver[];
26
+ };
27
+
28
+ /**
29
+ * Decorate `![](echo:...)` references that resolve to File objects with inline image previews.
30
+ */
31
+ export const image = (options: ImageOptions): Extension[] => {
32
+ const blobUrlCache: Record<string, string> = {};
33
+ const preloaded = new Set<string>();
34
+
35
+ const preload = (url: string) => {
36
+ if (!preloaded.has(url)) {
37
+ const img = document.createElement('img');
38
+ img.src = url;
39
+ preloaded.add(url);
40
+ }
41
+ };
42
+
43
+ return [
44
+ StateField.define({
45
+ create: (state) =>
46
+ Decoration.set(buildDecorations({ from: 0, to: state.doc.length, state, blobUrlCache, preload, options })),
47
+ update: (value, tr) => {
48
+ if (!tr.docChanged && !tr.selection) {
49
+ return value;
50
+ }
51
+
52
+ const cursor = tr.state.selection.main.head;
53
+ const oldCursor = tr.changes.mapPos(tr.startState.selection.main.head);
54
+ let from = Math.min(cursor, oldCursor);
55
+ let to = Math.max(cursor, oldCursor);
56
+ tr.changes.iterChangedRanges((_fromA, _toA, fromB, toB) => {
57
+ from = Math.min(from, fromB);
58
+ to = Math.max(to, toB);
59
+ });
60
+
61
+ from = tr.state.doc.lineAt(from).from;
62
+ to = tr.state.doc.lineAt(to).to;
63
+
64
+ return value.map(tr.changes).update({
65
+ filterFrom: from,
66
+ filterTo: to,
67
+ filter: () => false,
68
+ add: buildDecorations({ from, to, state: tr.state, blobUrlCache, preload, options }),
69
+ });
70
+ },
71
+ provide: (field) => EditorView.decorations.from(field),
72
+ }),
73
+ ];
74
+ };
75
+
76
+ const buildDecorations = ({
77
+ state,
78
+ from,
79
+ to,
80
+ blobUrlCache,
81
+ preload,
82
+ options: { space, resolvers },
83
+ }: {
84
+ state: EditorState;
85
+ from: number;
86
+ to: number;
87
+ blobUrlCache: Record<string, string>;
88
+ preload: (url: string) => void;
89
+ options: ImageOptions;
90
+ }) => {
91
+ const decorations: Range<Decoration>[] = [];
92
+ const cursor = state.selection.main.head;
93
+ syntaxTree(state).iterate({
94
+ enter: (node) => {
95
+ if (node.name !== 'Image') {
96
+ return;
97
+ }
98
+
99
+ const urlNode = node.node.getChild('URL');
100
+ if (!urlNode) {
101
+ return;
102
+ }
103
+
104
+ const hide = state.readOnly || cursor < node.from || cursor > node.to;
105
+ const urlText = state.sliceDoc(urlNode.from, urlNode.to);
106
+ if (!EID.isEID(urlText)) {
107
+ return;
108
+ }
109
+
110
+ let echoUri: string | undefined;
111
+ let echoSpaceId: string | undefined;
112
+ try {
113
+ const parsed = EID.parse(urlText);
114
+ echoUri = EID.getEntityId(parsed);
115
+ echoSpaceId = EID.getSpaceId(parsed);
116
+ } catch {
117
+ return;
118
+ }
119
+ if (!echoUri) {
120
+ return;
121
+ }
122
+
123
+ // Skip references to objects in other spaces.
124
+ if (echoSpaceId && echoSpaceId !== space.id) {
125
+ return;
126
+ }
127
+
128
+ const cacheKey = urlText;
129
+ const blobUrlPromise = (async () => {
130
+ const matched = await space.db.query(Filter.id(echoUri!)).first();
131
+ if (!matched || !Obj.instanceOf(File.File, matched)) {
132
+ return undefined;
133
+ }
134
+
135
+ let url: string | undefined;
136
+ const { data } = matched;
137
+ if (data._tag === 'inline') {
138
+ url = URL.createObjectURL(new Blob([data.bytes as BlobPart], { type: matched.type }));
139
+ } else if (/^(?:https?|data|blob):/i.test(data.url)) {
140
+ url = data.url;
141
+ } else {
142
+ const resolver = resolvers.find((r) => r.test(data.url));
143
+ url = await resolver?.resolve(data.url, matched, space);
144
+ }
145
+ if (!url) {
146
+ return undefined;
147
+ }
148
+ blobUrlCache[cacheKey] = url;
149
+ preload(url);
150
+ return url;
151
+ })();
152
+
153
+ decorations.push(
154
+ Decoration.replace({
155
+ block: true,
156
+ widget: new DxnImageWidget(urlText, blobUrlCache[cacheKey] ?? blobUrlPromise),
157
+ }).range(hide ? node.from : node.to, node.to),
158
+ );
159
+ },
160
+ from,
161
+ to,
162
+ });
163
+
164
+ return decorations;
165
+ };
166
+
167
+ const createImg = (view: EditorView, url: string) => {
168
+ const img = document.createElement('img');
169
+ img.setAttribute('src', url);
170
+ img.setAttribute('class', 'cm-image-with-loader');
171
+ if (view.state.field(focusField)) {
172
+ img.onload = () => img.classList.add('cm-loaded-image');
173
+ } else {
174
+ img.classList.add('cm-loaded-image');
175
+ }
176
+ return img;
177
+ };
178
+
179
+ class DxnImageWidget extends WidgetType {
180
+ constructor(
181
+ readonly _dxnUrl: string,
182
+ readonly _url: MaybePromise<string | undefined>,
183
+ ) {
184
+ super();
185
+ }
186
+
187
+ override eq(other: this) {
188
+ return this._dxnUrl === other._dxnUrl;
189
+ }
190
+
191
+ override toDOM(view: EditorView) {
192
+ if (typeof this._url === 'string') {
193
+ return createImg(view, this._url);
194
+ }
195
+
196
+ const widget = document.createElement('div');
197
+ widget.className = 'cm-image-wrapper';
198
+ const loader = document.createElement('div');
199
+ loader.className = 'mx-auto transition-opacity';
200
+ let loaderAdded = false;
201
+
202
+ const timeout = setTimeout(() => {
203
+ const root = createRoot(loader);
204
+ root.render(
205
+ <ThemeProvider tx={defaultTx}>
206
+ <Status indeterminate />
207
+ </ThemeProvider>,
208
+ );
209
+ widget.appendChild(loader);
210
+ loaderAdded = true;
211
+ }, WAIT_UNTIL_LOADER);
212
+
213
+ void Promise.resolve(this._url)
214
+ .then((url) => {
215
+ clearTimeout(timeout);
216
+ if (loaderAdded) {
217
+ widget.removeChild(loader);
218
+ }
219
+ if (url) {
220
+ widget.appendChild(createImg(view, url));
221
+ }
222
+ })
223
+ .catch(() => {
224
+ clearTimeout(timeout);
225
+ if (loaderAdded) {
226
+ widget.removeChild(loader);
227
+ }
228
+ });
229
+
230
+ return widget;
231
+ }
232
+ }
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './image';
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './skills';
6
+ export * from './meta';
7
+ export * from './types';
package/src/meta.ts ADDED
@@ -0,0 +1,9 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { Plugin } from '@dxos/app-framework';
6
+
7
+ import config from '../dx.config';
8
+
9
+ export const meta = Plugin.getMetaFromConfig(config);
@@ -0,0 +1,53 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { inlineBackend } from '../capabilities/inline-backend';
8
+ import { MAX_FILE_SIZE } from '../types';
9
+ import { FileTooLargeError, UnsupportedFileTypeError } from './create';
10
+
11
+ const makeFile = (name: string, type: string, bytes: Uint8Array): File => new File([bytes as BlobPart], name, { type });
12
+
13
+ // Database is unused by the inline backend, but the type signature requires it.
14
+ const stubDb: any = {};
15
+
16
+ describe('inlineBackend.upload', () => {
17
+ test('produces an inline FileData for a small PNG', async ({ expect }) => {
18
+ const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
19
+ const result = await inlineBackend.upload(makeFile('icon.png', 'image/png', bytes), stubDb);
20
+ expect(result.name).toBe('icon.png');
21
+ expect(result.type).toBe('image/png');
22
+ expect(result.size).toBe(bytes.byteLength);
23
+ expect(result.data._tag).toBe('inline');
24
+ expect(result.data._tag === 'inline' && result.data.bytes).toBeInstanceOf(Uint8Array);
25
+ expect(result.data._tag === 'inline' && result.data.bytes.byteLength).toBe(bytes.byteLength);
26
+ });
27
+
28
+ test('produces an inline FileData for a PDF', async ({ expect }) => {
29
+ const result = await inlineBackend.upload(makeFile('doc.pdf', 'application/pdf', new Uint8Array(1024)), stubDb);
30
+ expect(result.type).toBe('application/pdf');
31
+ expect(result.size).toBe(1024);
32
+ expect(result.data._tag).toBe('inline');
33
+ });
34
+
35
+ test('rejects files larger than 4MB', async ({ expect }) => {
36
+ const oversized = new Uint8Array(MAX_FILE_SIZE + 1);
37
+ await expect(inlineBackend.upload(makeFile('big.png', 'image/png', oversized), stubDb)).rejects.toThrow(
38
+ FileTooLargeError,
39
+ );
40
+ });
41
+
42
+ test('accepts video', async ({ expect }) => {
43
+ const result = await inlineBackend.upload(makeFile('movie.mp4', 'video/mp4', new Uint8Array(8)), stubDb);
44
+ expect(result.type).toBe('video/mp4');
45
+ expect(result.data._tag).toBe('inline');
46
+ });
47
+
48
+ test('rejects text/plain', async ({ expect }) => {
49
+ await expect(inlineBackend.upload(makeFile('notes.txt', 'text/plain', new Uint8Array(8)), stubDb)).rejects.toThrow(
50
+ UnsupportedFileTypeError,
51
+ );
52
+ });
53
+ });
@@ -0,0 +1,87 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { Capabilities, Capability } from '@dxos/app-framework';
8
+ import { Operation } from '@dxos/compute';
9
+
10
+ import { File, FileCapabilities, FileOperation, MAX_FILE_SIZE, Settings, isAcceptedMimeType } from '../types';
11
+
12
+ export class UnsupportedFileTypeError extends Error {
13
+ constructor(public readonly type: string) {
14
+ super(`Unsupported file type: ${type}`);
15
+ this.name = 'UnsupportedFileTypeError';
16
+ }
17
+ }
18
+
19
+ export class FileTooLargeError extends Error {
20
+ constructor(
21
+ public readonly size: number,
22
+ public readonly limit: number = MAX_FILE_SIZE,
23
+ ) {
24
+ super(`File is too large: ${size} bytes (limit: ${limit} bytes)`);
25
+ this.name = 'FileTooLargeError';
26
+ }
27
+ }
28
+
29
+ export class NoBackendError extends Error {
30
+ constructor() {
31
+ super('No file storage backend is registered.');
32
+ this.name = 'NoBackendError';
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Resolve the configured {@link FileCapabilities.Backend} from the plugin
38
+ * settings atom. Falls back to the inline backend (or the first registered
39
+ * one) if the configured id is missing or settings aren't available.
40
+ */
41
+ export const resolveActiveBackend = Effect.gen(function* () {
42
+ const backends = yield* Capability.getAll(FileCapabilities.Backend);
43
+ if (backends.length === 0) {
44
+ return yield* Effect.fail(new NoBackendError());
45
+ }
46
+
47
+ const settingsAtomOpt = yield* Capability.get(FileCapabilities.SettingsAtom).pipe(Effect.option);
48
+ const registryOpt = yield* Capability.get(Capabilities.AtomRegistry).pipe(Effect.option);
49
+
50
+ if (settingsAtomOpt._tag === 'Some' && registryOpt._tag === 'Some') {
51
+ const settings = registryOpt.value.get(settingsAtomOpt.value) as Settings.Settings;
52
+ const id = settings.backend ?? Settings.DEFAULT_BACKEND_ID;
53
+ const match = backends.find((b) => b.id === id);
54
+ if (match) {
55
+ return match;
56
+ }
57
+ }
58
+
59
+ return backends.find((b) => b.id === Settings.DEFAULT_BACKEND_ID) ?? backends[0];
60
+ });
61
+
62
+ const handler: Operation.WithHandler<typeof FileOperation.Create> = FileOperation.Create.pipe(
63
+ Operation.withHandler(
64
+ Effect.fnUntraced(function* ({ file, db }) {
65
+ // Validate before hitting the backend so the contract is consistent regardless of backend.
66
+ if (!isAcceptedMimeType(file.type)) {
67
+ return yield* Effect.fail(new UnsupportedFileTypeError(file.type));
68
+ }
69
+ if (file.size > MAX_FILE_SIZE) {
70
+ return yield* Effect.fail(new FileTooLargeError(file.size));
71
+ }
72
+ const backend = yield* resolveActiveBackend;
73
+ const info = yield* Effect.promise(() => backend.upload(file, db));
74
+ return {
75
+ object: File.make({
76
+ name: info.name,
77
+ type: info.type,
78
+ size: info.size,
79
+ data: info.data,
80
+ timestamp: new Date().toISOString(),
81
+ }),
82
+ };
83
+ }),
84
+ ),
85
+ );
86
+
87
+ export default handler;
@@ -0,0 +1,10 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { OperationHandlerSet } from '@dxos/compute';
6
+
7
+ export const FileOperationHandlerSet = OperationHandlerSet.lazy(
8
+ () => import('./create'),
9
+ () => import('./read'),
10
+ );
@@ -0,0 +1,35 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+
7
+ import { Operation } from '@dxos/compute';
8
+ import { Database } from '@dxos/echo';
9
+ import { ContentBlock } from '@dxos/types';
10
+
11
+ import { FileOperation } from '../types';
12
+
13
+ const handler: Operation.WithHandler<typeof FileOperation.Read> = FileOperation.Read.pipe(
14
+ Operation.withHandler(
15
+ Effect.fn(function* ({ file }) {
16
+ const obj = yield* Database.load(file);
17
+ const url =
18
+ obj.data._tag === 'inline'
19
+ ? `data:${obj.type};base64,${Buffer.from(obj.data.bytes).toString('base64')}`
20
+ : obj.data.url;
21
+
22
+ return ContentBlock.ContentBlockResult.make({
23
+ content: [
24
+ ContentBlock.File.make({
25
+ url,
26
+ name: obj.name,
27
+ mediaType: obj.type,
28
+ }),
29
+ ],
30
+ });
31
+ }),
32
+ ),
33
+ );
34
+
35
+ export default handler;