@anymux/ui-kit 0.1.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 (244) hide show
  1. package/dist/ExplorerLayout-CSIJd7N4.js +105 -0
  2. package/dist/ExplorerLayout-CSIJd7N4.js.map +1 -0
  3. package/dist/FileBrowserContext-B6jixa2j.js +11 -0
  4. package/dist/FileBrowserContext-B6jixa2j.js.map +1 -0
  5. package/dist/calendar-DSlrbHoj.js +761 -0
  6. package/dist/calendar-DSlrbHoj.js.map +1 -0
  7. package/dist/calendar.d.ts +3 -0
  8. package/dist/calendar.js +3 -0
  9. package/dist/contacts-DQXTZzHc.js +539 -0
  10. package/dist/contacts-DQXTZzHc.js.map +1 -0
  11. package/dist/contacts.d.ts +3 -0
  12. package/dist/contacts.js +3 -0
  13. package/dist/file-browser-m5atC3kF.js +6755 -0
  14. package/dist/file-browser-m5atC3kF.js.map +1 -0
  15. package/dist/file-browser.d.ts +11 -0
  16. package/dist/file-browser.js +9 -0
  17. package/dist/git-B55e6LL-.js +561 -0
  18. package/dist/git-B55e6LL-.js.map +1 -0
  19. package/dist/git.d.ts +2 -0
  20. package/dist/git.js +3 -0
  21. package/dist/iconMap-V4B8P-Uh.js +206 -0
  22. package/dist/iconMap-V4B8P-Uh.js.map +1 -0
  23. package/dist/icons-CIsIOZXR.js +0 -0
  24. package/dist/icons.d.ts +2 -0
  25. package/dist/icons.js +4 -0
  26. package/dist/index-BNmNIWBL.d.ts +71 -0
  27. package/dist/index-BNmNIWBL.d.ts.map +1 -0
  28. package/dist/index-Bryv_GCG.d.ts +1481 -0
  29. package/dist/index-Bryv_GCG.d.ts.map +1 -0
  30. package/dist/index-CuQIjSXs.d.ts +134 -0
  31. package/dist/index-CuQIjSXs.d.ts.map +1 -0
  32. package/dist/index-DSu19mq0.d.ts +153 -0
  33. package/dist/index-DSu19mq0.d.ts.map +1 -0
  34. package/dist/index-DmsyeHFr.d.ts +149 -0
  35. package/dist/index-DmsyeHFr.d.ts.map +1 -0
  36. package/dist/index-DxnJ8FYM.d.ts +17 -0
  37. package/dist/index-DxnJ8FYM.d.ts.map +1 -0
  38. package/dist/index-DzfY1Tok.d.ts +32 -0
  39. package/dist/index-DzfY1Tok.d.ts.map +1 -0
  40. package/dist/index-Ml_SgiKa.d.ts +1847 -0
  41. package/dist/index-Ml_SgiKa.d.ts.map +1 -0
  42. package/dist/index-kHr9udZD.d.ts +1025 -0
  43. package/dist/index-kHr9udZD.d.ts.map +1 -0
  44. package/dist/index.d.ts +11 -0
  45. package/dist/index.js +15 -0
  46. package/dist/layout-Ca_4r8ka.js +89 -0
  47. package/dist/layout-Ca_4r8ka.js.map +1 -0
  48. package/dist/layout.d.ts +2 -0
  49. package/dist/layout.js +5 -0
  50. package/dist/list-CxfT6hix.js +6831 -0
  51. package/dist/list-CxfT6hix.js.map +1 -0
  52. package/dist/list.d.ts +2 -0
  53. package/dist/list.js +5 -0
  54. package/dist/media-DZ292aKK.js +557 -0
  55. package/dist/media-DZ292aKK.js.map +1 -0
  56. package/dist/media.d.ts +3 -0
  57. package/dist/media.js +3 -0
  58. package/dist/tree-Dd9Z0Aso.js +3351 -0
  59. package/dist/tree-Dd9Z0Aso.js.map +1 -0
  60. package/dist/tree.d.ts +2 -0
  61. package/dist/tree.js +6 -0
  62. package/dist/types-common-CB3kRek8.d.ts +26 -0
  63. package/dist/types-common-CB3kRek8.d.ts.map +1 -0
  64. package/dist/utils-B4fdKKsy.js +3 -0
  65. package/package.json +109 -0
  66. package/src/calendar/AgendaView.tsx +37 -0
  67. package/src/calendar/CalendarBrowser.tsx +90 -0
  68. package/src/calendar/CalendarModel.ts +142 -0
  69. package/src/calendar/CalendarSidebar.tsx +81 -0
  70. package/src/calendar/DayView.tsx +76 -0
  71. package/src/calendar/EventCard.tsx +51 -0
  72. package/src/calendar/MockCalendarProvider.ts +98 -0
  73. package/src/calendar/MonthView.tsx +77 -0
  74. package/src/calendar/WeekView.tsx +129 -0
  75. package/src/calendar/index.ts +18 -0
  76. package/src/calendar/types.ts +25 -0
  77. package/src/contacts/ContactAvatar.tsx +35 -0
  78. package/src/contacts/ContactBrowser.tsx +56 -0
  79. package/src/contacts/ContactCard.tsx +37 -0
  80. package/src/contacts/ContactDetail.tsx +63 -0
  81. package/src/contacts/ContactGroupSidebar.tsx +40 -0
  82. package/src/contacts/ContactList.tsx +32 -0
  83. package/src/contacts/ContactListModel.ts +120 -0
  84. package/src/contacts/MockContactProvider.ts +77 -0
  85. package/src/contacts/index.ts +17 -0
  86. package/src/contacts/types.ts +26 -0
  87. package/src/demos/CalendarBrowserDemo.tsx +15 -0
  88. package/src/demos/ContactBrowserDemo.tsx +15 -0
  89. package/src/demos/MediaBrowserDemo.tsx +15 -0
  90. package/src/file-browser/adapters/DocumentViewerAdapter.ts +371 -0
  91. package/src/file-browser/adapters/FileSystemBridge.ts +168 -0
  92. package/src/file-browser/adapters/GitBrowserAdapter.ts +546 -0
  93. package/src/file-browser/adapters/README.md +504 -0
  94. package/src/file-browser/adapters/index.ts +27 -0
  95. package/src/file-browser/adapters/types.ts +70 -0
  96. package/src/file-browser/architecture.md +645 -0
  97. package/src/file-browser/components/CreateItemDialog.tsx +71 -0
  98. package/src/file-browser/components/DeleteConfirmDialog.tsx +58 -0
  99. package/src/file-browser/components/FileBrowser.tsx +473 -0
  100. package/src/file-browser/components/FileBrowserContent.tsx +209 -0
  101. package/src/file-browser/components/FileBrowserHeader.tsx +151 -0
  102. package/src/file-browser/components/FileBrowserToolbar.tsx +145 -0
  103. package/src/file-browser/components/LeftPanel/LeftPanel.tsx +103 -0
  104. package/src/file-browser/components/LeftPanel/LeftPanelTabs.tsx +70 -0
  105. package/src/file-browser/components/LeftPanel/TreeNavigationView.tsx +256 -0
  106. package/src/file-browser/components/PreviewPane.tsx +146 -0
  107. package/src/file-browser/components/RightPanel/FilePreview.tsx +219 -0
  108. package/src/file-browser/components/RightPanel/RightPanel.tsx +186 -0
  109. package/src/file-browser/components/RightPanel/RightPanelToolbar.tsx +113 -0
  110. package/src/file-browser/components/UploadProgress.tsx +123 -0
  111. package/src/file-browser/components/ViewerHost.tsx +208 -0
  112. package/src/file-browser/components/mobile/MobileNavigation.tsx +227 -0
  113. package/src/file-browser/components/navigation/NavigationButtons.tsx +171 -0
  114. package/src/file-browser/components/shared/ErrorBoundary.tsx +116 -0
  115. package/src/file-browser/components/shared/FileBrowserItem.tsx +195 -0
  116. package/src/file-browser/components/shared/FileIcon.tsx +169 -0
  117. package/src/file-browser/components/toolbar/ViewModeToggle.tsx +200 -0
  118. package/src/file-browser/components/views/ListView/ListView.tsx +484 -0
  119. package/src/file-browser/components/views/ThumbnailView/ThumbnailView.tsx +323 -0
  120. package/src/file-browser/components/views/TreeView/TreeNode.tsx +186 -0
  121. package/src/file-browser/components/views/TreeView/TreeNodeList.tsx +191 -0
  122. package/src/file-browser/components/views/TreeView/TreeView.tsx +200 -0
  123. package/src/file-browser/components/views/TreemapView/TreemapView.tsx +339 -0
  124. package/src/file-browser/context/FileBrowserContext.tsx +13 -0
  125. package/src/file-browser/examples/BasicUsage.tsx +20 -0
  126. package/src/file-browser/index.ts +98 -0
  127. package/src/file-browser/models/FileBrowserModel.ts +623 -0
  128. package/src/file-browser/models/LeftPanelManagerModel.ts +105 -0
  129. package/src/file-browser/models/NavigationManagerModel.ts +312 -0
  130. package/src/file-browser/models/ResponsiveLayoutManagerModel.ts +437 -0
  131. package/src/file-browser/models/RightPanelManagerModel.ts +190 -0
  132. package/src/file-browser/models/SelectionManagerModel.ts +252 -0
  133. package/src/file-browser/models/ToolbarManagerModel.ts +144 -0
  134. package/src/file-browser/models/UploadModel.ts +147 -0
  135. package/src/file-browser/models/ViewModeManagerModel.ts +185 -0
  136. package/src/file-browser/models/ViewerHostModel.ts +44 -0
  137. package/src/file-browser/models/ui/ListViewUIModel.ts +265 -0
  138. package/src/file-browser/models/ui/PreviewUIModel.ts +297 -0
  139. package/src/file-browser/models/ui/ThumbnailViewUIModel.ts +254 -0
  140. package/src/file-browser/models/ui/TreeViewUIModel.ts +128 -0
  141. package/src/file-browser/models/ui/TreemapViewUIModel.ts +350 -0
  142. package/src/file-browser/providers/FileSystemListProvider.ts +552 -0
  143. package/src/file-browser/providers/FileSystemProvider.ts +401 -0
  144. package/src/file-browser/providers/FileSystemTreeProvider.ts +231 -0
  145. package/src/file-browser/providers/GitProvider.ts +337 -0
  146. package/src/file-browser/providers/GitRepositoryProvider.ts +376 -0
  147. package/src/file-browser/providers/IFileBrowserProvider.ts +56 -0
  148. package/src/file-browser/providers/MemoryProvider.ts +303 -0
  149. package/src/file-browser/providers/index.ts +4 -0
  150. package/src/file-browser/registry/ViewerRegistry.ts +551 -0
  151. package/src/file-browser/registry/types.ts +144 -0
  152. package/src/file-browser/scripts/performanceBenchmark.ts +553 -0
  153. package/src/file-browser/services/ThumbnailCacheService.ts +128 -0
  154. package/src/file-browser/tasks.md +537 -0
  155. package/src/file-browser/types/FileBrowserTypes.ts +126 -0
  156. package/src/file-browser/types/ProviderTypes.ts +155 -0
  157. package/src/file-browser/types/UITypes.ts +235 -0
  158. package/src/file-browser/types/ViewModeTypes.ts +150 -0
  159. package/src/file-browser/utils/gestures.ts +327 -0
  160. package/src/file-browser/utils/performance.ts +563 -0
  161. package/src/file-browser/viewers/ImageViewer.tsx +163 -0
  162. package/src/file-browser/viewers/ImageViewerModel.ts +79 -0
  163. package/src/file-browser/viewers/TextViewer.tsx +95 -0
  164. package/src/file-browser/viewers/UnsupportedFileViewer.tsx +57 -0
  165. package/src/file-browser/viewers/index.ts +61 -0
  166. package/src/git/BranchList.tsx +128 -0
  167. package/src/git/CommitGraph.tsx +239 -0
  168. package/src/git/CommitList.tsx +258 -0
  169. package/src/git/DiffViewer.tsx +219 -0
  170. package/src/git/index.ts +4 -0
  171. package/src/icons/iconMap.ts +146 -0
  172. package/src/icons/index.ts +9 -0
  173. package/src/index.ts +13 -0
  174. package/src/layout/README.md +307 -0
  175. package/src/layout/components/ExplorerLayout/ExplorerLayout.tsx +178 -0
  176. package/src/layout/examples/SimpleExample.tsx +60 -0
  177. package/src/layout/index.ts +6 -0
  178. package/src/lib/utils.ts +1 -0
  179. package/src/list/README.md +303 -0
  180. package/src/list/architecture.md +807 -0
  181. package/src/list/components/CalculatedGridView.tsx +252 -0
  182. package/src/list/components/DragPreview.tsx +102 -0
  183. package/src/list/components/ListContextMenu.tsx +274 -0
  184. package/src/list/components/ListItem.tsx +761 -0
  185. package/src/list/components/ListItems.tsx +919 -0
  186. package/src/list/components/MasonryView.tsx +241 -0
  187. package/src/list/components/SearchFilter.tsx +44 -0
  188. package/src/list/components/TreemapView.tsx +709 -0
  189. package/src/list/components/ViewSizeControls.tsx +205 -0
  190. package/src/list/components/ViewTypeSelector.tsx +312 -0
  191. package/src/list/components/VirtualizedDetailsView.tsx +231 -0
  192. package/src/list/components/VirtualizedGrid.tsx +164 -0
  193. package/src/list/components/VirtualizedList.tsx +154 -0
  194. package/src/list/components/VirtualizedMasonryView.tsx +344 -0
  195. package/src/list/components/shared/EmptyState.tsx +103 -0
  196. package/src/list/components/shared/ErrorBoundary.tsx +123 -0
  197. package/src/list/components/shared/ErrorDisplay.tsx +100 -0
  198. package/src/list/components/shared/ListLoader.tsx +146 -0
  199. package/src/list/components/shared/LoadingIndicator.tsx +80 -0
  200. package/src/list/index.ts +92 -0
  201. package/src/list/models/ListItemsModel.ts +1301 -0
  202. package/src/list/models/TreemapModel.ts +204 -0
  203. package/src/list/providers/ListItemsProvider.ts +313 -0
  204. package/src/list/providers/TestListProvider.ts +604 -0
  205. package/src/list/tasks.md +937 -0
  206. package/src/list/types/ListTypes.ts +178 -0
  207. package/src/list/utils/BenchmarkLogger.ts +243 -0
  208. package/src/list/utils/DragDropManager.ts +320 -0
  209. package/src/list/utils/GridLayoutCalculator.ts +290 -0
  210. package/src/list/utils/ListAccessibility.ts +367 -0
  211. package/src/list/utils/ListKeyboard.ts +414 -0
  212. package/src/list/utils/MasonryLayoutCalculator.ts +302 -0
  213. package/src/list/utils/MasonryLayoutEngine.ts +401 -0
  214. package/src/list/utils/__tests__/MasonryLayoutEngine.test.ts +157 -0
  215. package/src/list/utils/__tests__/VirtualizedMasonryView.test.tsx +251 -0
  216. package/src/media/AlbumSidebar.tsx +48 -0
  217. package/src/media/MediaBrowser.tsx +92 -0
  218. package/src/media/MediaBrowserModel.ts +138 -0
  219. package/src/media/MediaGrid.tsx +50 -0
  220. package/src/media/MediaList.tsx +49 -0
  221. package/src/media/MediaPreview.tsx +63 -0
  222. package/src/media/MediaTimeline.tsx +38 -0
  223. package/src/media/MockMediaProvider.ts +70 -0
  224. package/src/media/index.ts +18 -0
  225. package/src/media/types.ts +21 -0
  226. package/src/styles/variables.css +60 -0
  227. package/src/tree/DEVELOPMENT_SUMMARY.md +170 -0
  228. package/src/tree/__tests__/TreeModel.test.ts +16 -0
  229. package/src/tree/architecture.md +530 -0
  230. package/src/tree/components/Tree.tsx +283 -0
  231. package/src/tree/components/TreeCheckbox.tsx +147 -0
  232. package/src/tree/components/TreeContextMenu.tsx +139 -0
  233. package/src/tree/components/TreeNodeList.tsx +329 -0
  234. package/src/tree/components/TreeTable.tsx +382 -0
  235. package/src/tree/index.ts +58 -0
  236. package/src/tree/models/TreeModel.ts +839 -0
  237. package/src/tree/providers/SimpleTreeProvider.ts +463 -0
  238. package/src/tree/providers/TestTreeProvider.ts +946 -0
  239. package/src/tree/providers/TreeProvider.ts +308 -0
  240. package/src/tree/tasks.md +2046 -0
  241. package/src/tree/types/TreeTypes.ts +279 -0
  242. package/src/tree/utils/SelectionTheme.ts +150 -0
  243. package/src/tree/utils/logger.ts +203 -0
  244. package/src/types-common.ts +21 -0
@@ -0,0 +1,551 @@
1
+ import React from 'react';
2
+ import type {
3
+ FileViewerPlugin,
4
+ FileMatchContext,
5
+ ResolvedViewer,
6
+ } from './types';
7
+
8
+ // Re-export types from types.ts for convenience
9
+ export type { FileViewerPlugin, FileMatchContext, ResolvedViewer, ToolbarAction, KeyboardShortcut } from './types';
10
+
11
+ // ---- Legacy interfaces (kept for backward compatibility) ----
12
+
13
+ export interface ViewerConfig {
14
+ id: string;
15
+ name: string;
16
+ description?: string;
17
+ extensions: string[];
18
+ component: React.ComponentType<ViewerProps>;
19
+ priority?: number;
20
+ canEdit?: boolean;
21
+ isDefault?: boolean;
22
+ }
23
+
24
+ export interface ViewerProps {
25
+ file: {
26
+ path: string;
27
+ name: string;
28
+ content?: string | ArrayBuffer;
29
+ size?: number;
30
+ mimeType?: string;
31
+ };
32
+ onClose?: () => void;
33
+ onSave?: (content: string | ArrayBuffer) => Promise<void>;
34
+ className?: string;
35
+ readOnly?: boolean;
36
+ /** Callback for viewer to register toolbar controls rendered in parent toolbar */
37
+ onToolbarExtras?: (extras: React.ReactNode) => void;
38
+
39
+ // ---- NEW: Edit lifecycle ----
40
+
41
+ /**
42
+ * Called by the plugin when the content becomes dirty (modified).
43
+ * The host uses this to show unsaved-changes indicators and
44
+ * to prompt before closing.
45
+ */
46
+ onDirtyChange?: (isDirty: boolean) => void;
47
+
48
+ /**
49
+ * Whether to render in compact/preview mode (inside the preview pane)
50
+ * vs full mode (inside the main viewer area).
51
+ * Plugins can adjust their layout based on this.
52
+ */
53
+ mode?: 'preview' | 'full';
54
+
55
+ /**
56
+ * Content fetcher for streaming/on-demand loading.
57
+ * Plugins that handle large files (PDF, video) can use this
58
+ * instead of receiving the full content upfront.
59
+ */
60
+ fetchContent?: () => Promise<string | ArrayBuffer>;
61
+
62
+ /**
63
+ * URL for the file if it can be accessed directly (e.g., S3 presigned URL).
64
+ * Useful for media players that need a URL, not a buffer.
65
+ */
66
+ contentUrl?: string;
67
+ }
68
+
69
+ // ---- Match type bonus constants ----
70
+
71
+ const MATCH_BONUS_EXTENSION = 80;
72
+ const MATCH_BONUS_MIME = 70;
73
+ const MATCH_BONUS_PATTERN = 60;
74
+
75
+ // ---- Adapter: wrap legacy ViewerConfig into FileViewerPlugin ----
76
+
77
+ export function viewerConfigToPlugin(config: ViewerConfig): FileViewerPlugin {
78
+ const plugin: FileViewerPlugin = {
79
+ id: config.id,
80
+ name: config.name,
81
+ extensions: config.extensions.map(ext => ext.toLowerCase().replace(/^\./, '')),
82
+ capabilities: {
83
+ canPreview: true,
84
+ canFullscreen: true,
85
+ },
86
+ priority: config.priority ?? 0,
87
+ component: config.component,
88
+ };
89
+ if (config.description !== undefined) plugin.description = config.description;
90
+ if (config.canEdit !== undefined) plugin.capabilities.canEdit = config.canEdit;
91
+ if (config.isDefault !== undefined) plugin.isDefault = config.isDefault;
92
+ return plugin;
93
+ }
94
+
95
+ // ---- Adapter: convert FileViewerPlugin back to ViewerConfig (for backward-compat getters) ----
96
+
97
+ function pluginToViewerConfig(plugin: FileViewerPlugin): ViewerConfig {
98
+ const config: ViewerConfig = {
99
+ id: plugin.id,
100
+ name: plugin.name,
101
+ extensions: plugin.extensions ?? [],
102
+ component: resolveComponent(plugin),
103
+ priority: plugin.priority ?? 0,
104
+ };
105
+ if (plugin.description !== undefined) config.description = plugin.description;
106
+ if (plugin.capabilities.canEdit !== undefined) config.canEdit = plugin.capabilities.canEdit;
107
+ if (plugin.isDefault !== undefined) config.isDefault = plugin.isDefault;
108
+ return config;
109
+ }
110
+
111
+ // ---- Lazy component cache ----
112
+
113
+ const lazyCache = new Map<string, React.LazyExoticComponent<React.ComponentType<ViewerProps>>>();
114
+
115
+ function resolveComponent(plugin: FileViewerPlugin): React.ComponentType<ViewerProps> {
116
+ if (plugin.component) {
117
+ return plugin.component;
118
+ }
119
+ if (plugin.load) {
120
+ if (!lazyCache.has(plugin.id)) {
121
+ lazyCache.set(plugin.id, React.lazy(plugin.load));
122
+ }
123
+ return lazyCache.get(plugin.id)!;
124
+ }
125
+ throw new Error(`Plugin "${plugin.id}" has no component or loader`);
126
+ }
127
+
128
+ // ---- Simple glob matching for filePatterns ----
129
+
130
+ function matchesGlob(filename: string, pattern: string): boolean {
131
+ // Convert simple glob pattern to regex
132
+ // Supports: * (any chars), ? (single char), and literal strings
133
+ const escaped = pattern
134
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
135
+ .replace(/\*/g, '.*')
136
+ .replace(/\?/g, '.');
137
+ const regex = new RegExp(`^${escaped}$`, 'i');
138
+ return regex.test(filename);
139
+ }
140
+
141
+ // ---- ViewerRegistry class ----
142
+
143
+ export class ViewerRegistry {
144
+ // ---- Legacy storage (kept for backward compat) ----
145
+ private viewers = new Map<string, ViewerConfig>();
146
+ private extensionMap = new Map<string, ViewerConfig[]>();
147
+
148
+ // ---- New plugin storage ----
149
+ private plugins = new Map<string, FileViewerPlugin>();
150
+ private extensionIndex = new Map<string, FileViewerPlugin[]>();
151
+ private mimeTypeIndex = new Map<string, FileViewerPlugin[]>();
152
+ private patternPlugins: FileViewerPlugin[] = [];
153
+
154
+ // ==============================
155
+ // NEW: Plugin API
156
+ // ==============================
157
+
158
+ /**
159
+ * Register a plugin. Replaces any existing plugin with the same ID.
160
+ */
161
+ registerPlugin(plugin: FileViewerPlugin): void {
162
+ if (!plugin.component && !plugin.load) {
163
+ throw new Error(`Plugin "${plugin.id}" must provide either component or load()`);
164
+ }
165
+
166
+ // Remove existing plugin with same ID (if any)
167
+ this.unregisterPlugin(plugin.id);
168
+
169
+ this.plugins.set(plugin.id, plugin);
170
+ this.rebuildIndexes();
171
+
172
+ // Also register in legacy storage for backward compat
173
+ this.syncPluginToLegacy(plugin);
174
+ }
175
+
176
+ /**
177
+ * Unregister a plugin by ID.
178
+ */
179
+ unregisterPlugin(pluginId: string): void {
180
+ if (!this.plugins.has(pluginId)) return;
181
+
182
+ this.plugins.delete(pluginId);
183
+ this.rebuildIndexes();
184
+
185
+ // Also remove from legacy maps
186
+ this.removeLegacy(pluginId);
187
+
188
+ // Clean lazy cache
189
+ lazyCache.delete(pluginId);
190
+ }
191
+
192
+ /**
193
+ * Find the best viewer for a given file.
194
+ *
195
+ * Matching order with scoring:
196
+ * 1. Extension match (bonus: 80)
197
+ * 2. MIME type match (bonus: 70)
198
+ * 3. Filename pattern match (bonus: 60)
199
+ * 4. canHandle() callback (bonus: canHandle return value 0-100)
200
+ * 5. Default plugin (isDefault: true, bonus: 0)
201
+ *
202
+ * Final score = matchTypeBonus + plugin.priority + canHandle score (if applicable)
203
+ */
204
+ resolveViewer(
205
+ file: FileMatchContext,
206
+ options?: { preferEditable?: boolean }
207
+ ): ResolvedViewer | null {
208
+ const candidates = this.findCandidates(file);
209
+
210
+ if (options?.preferEditable) {
211
+ const editable = candidates.filter(c => c.plugin.capabilities.canEdit);
212
+ if (editable.length > 0) return editable[0]!;
213
+ }
214
+
215
+ return candidates[0] ?? null;
216
+ }
217
+
218
+ /**
219
+ * Find ALL viewers that can handle a file, sorted by score (descending).
220
+ * Used for "Open with..." menu.
221
+ */
222
+ resolveAllViewers(file: FileMatchContext): ResolvedViewer[] {
223
+ return this.findCandidates(file);
224
+ }
225
+
226
+ /**
227
+ * Get a plugin by ID.
228
+ */
229
+ getPluginById(pluginId: string): FileViewerPlugin | null {
230
+ return this.plugins.get(pluginId) ?? null;
231
+ }
232
+
233
+ /**
234
+ * Get all registered plugins.
235
+ */
236
+ getAllPlugins(): FileViewerPlugin[] {
237
+ return Array.from(this.plugins.values()).sort((a, b) => a.name.localeCompare(b.name));
238
+ }
239
+
240
+ /**
241
+ * Build a FileMatchContext from a filename (convenience helper).
242
+ */
243
+ buildFileMatchContext(
244
+ filename: string,
245
+ opts?: { path?: string; mimeType?: string; size?: number }
246
+ ): FileMatchContext {
247
+ const ctx: FileMatchContext = {
248
+ name: filename,
249
+ path: opts?.path ?? filename,
250
+ extension: this.extractExtension(filename),
251
+ };
252
+ if (opts?.mimeType !== undefined) ctx.mimeType = opts.mimeType;
253
+ if (opts?.size !== undefined) ctx.size = opts.size;
254
+ return ctx;
255
+ }
256
+
257
+ // ==============================
258
+ // LEGACY API (backward compat)
259
+ // ==============================
260
+
261
+ registerViewer(config: ViewerConfig): void {
262
+ // Register via plugin API, which also syncs to legacy
263
+ this.registerPlugin(viewerConfigToPlugin(config));
264
+ }
265
+
266
+ unregisterViewer(viewerId: string): void {
267
+ this.unregisterPlugin(viewerId);
268
+ }
269
+
270
+ getViewer(filename: string, preferEditable = false): ViewerConfig | null {
271
+ const extension = this.extractExtension(filename);
272
+ const viewers = this.extensionMap.get(extension) || [];
273
+
274
+ if (viewers.length === 0) {
275
+ return this.getDefaultViewer();
276
+ }
277
+
278
+ let candidates = viewers;
279
+ if (preferEditable) {
280
+ const editableViewers = viewers.filter(v => v.canEdit);
281
+ if (editableViewers.length > 0) {
282
+ candidates = editableViewers;
283
+ }
284
+ }
285
+
286
+ return candidates[0] || null;
287
+ }
288
+
289
+ getViewers(filename: string): ViewerConfig[] {
290
+ const extension = this.extractExtension(filename);
291
+ return [...(this.extensionMap.get(extension) || [])];
292
+ }
293
+
294
+ canHandle(filename: string): boolean {
295
+ const extension = this.extractExtension(filename);
296
+ if (this.extensionMap.has(extension)) return true;
297
+
298
+ // Also check plugin pattern/canHandle matching
299
+ const ctx = this.buildFileMatchContext(filename);
300
+ for (const plugin of this.patternPlugins) {
301
+ if (plugin.filePatterns) {
302
+ for (const pattern of plugin.filePatterns) {
303
+ if (matchesGlob(filename, pattern)) return true;
304
+ }
305
+ }
306
+ if (plugin.canHandle) {
307
+ if (plugin.canHandle(ctx) > 0) return true;
308
+ }
309
+ }
310
+
311
+ return false;
312
+ }
313
+
314
+ getAllViewers(): ViewerConfig[] {
315
+ return Array.from(this.viewers.values()).sort((a, b) => a.name.localeCompare(b.name));
316
+ }
317
+
318
+ getViewerById(id: string): ViewerConfig | null {
319
+ return this.viewers.get(id) || null;
320
+ }
321
+
322
+ getFileCategory(filename: string): string {
323
+ const extension = this.extractExtension(filename);
324
+
325
+ const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp', 'bmp', 'ico'];
326
+ const videoExts = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm', 'mkv'];
327
+ const audioExts = ['mp3', 'wav', 'flac', 'aac', 'ogg', 'wma', 'm4a'];
328
+ const codeExts = ['js', 'jsx', 'ts', 'tsx', 'py', 'java', 'cpp', 'c', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt', 'html', 'css', 'scss'];
329
+ const textExts = ['txt', 'md', 'mdx', 'rst', 'rtf'];
330
+ const documentExts = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'];
331
+ const archiveExts = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
332
+
333
+ if (imageExts.includes(extension)) return 'image';
334
+ if (videoExts.includes(extension)) return 'video';
335
+ if (audioExts.includes(extension)) return 'audio';
336
+ if (codeExts.includes(extension)) return 'code';
337
+ if (textExts.includes(extension)) return 'text';
338
+ if (documentExts.includes(extension)) return 'document';
339
+ if (archiveExts.includes(extension)) return 'archive';
340
+
341
+ return 'file';
342
+ }
343
+
344
+ // ==============================
345
+ // INTERNAL
346
+ // ==============================
347
+
348
+ private extractExtension(filename: string): string {
349
+ const lastDot = filename.lastIndexOf('.');
350
+ if (lastDot === -1 || lastDot === filename.length - 1) {
351
+ return '';
352
+ }
353
+ return filename.slice(lastDot + 1).toLowerCase();
354
+ }
355
+
356
+ private getDefaultViewer(): ViewerConfig | null {
357
+ for (const viewer of this.viewers.values()) {
358
+ if (viewer.isDefault) {
359
+ return viewer;
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+
365
+ /**
366
+ * Find all candidate viewers for a file, scored and sorted descending.
367
+ */
368
+ private findCandidates(file: FileMatchContext): ResolvedViewer[] {
369
+ const scored: Array<{ plugin: FileViewerPlugin; score: number }> = [];
370
+
371
+ // 1. Extension match
372
+ if (file.extension) {
373
+ const byExt = this.extensionIndex.get(file.extension) || [];
374
+ for (const plugin of byExt) {
375
+ let score = MATCH_BONUS_EXTENSION + (plugin.priority ?? 0);
376
+ if (plugin.canHandle) {
377
+ const canHandleScore = plugin.canHandle(file);
378
+ if (canHandleScore <= 0) continue; // canHandle rejected
379
+ score += canHandleScore;
380
+ }
381
+ scored.push({ plugin, score });
382
+ }
383
+ }
384
+
385
+ // 2. MIME type match
386
+ if (file.mimeType) {
387
+ const byMime = this.mimeTypeIndex.get(file.mimeType) || [];
388
+ for (const plugin of byMime) {
389
+ // Skip if already scored by extension
390
+ if (scored.some(s => s.plugin.id === plugin.id)) continue;
391
+
392
+ let score = MATCH_BONUS_MIME + (plugin.priority ?? 0);
393
+ if (plugin.canHandle) {
394
+ const canHandleScore = plugin.canHandle(file);
395
+ if (canHandleScore <= 0) continue;
396
+ score += canHandleScore;
397
+ }
398
+ scored.push({ plugin, score });
399
+ }
400
+ }
401
+
402
+ // 3. Pattern match + canHandle-only plugins
403
+ for (const plugin of this.patternPlugins) {
404
+ // Skip if already scored
405
+ if (scored.some(s => s.plugin.id === plugin.id)) continue;
406
+
407
+ let matched = false;
408
+ let matchBonus = 0;
409
+
410
+ // Check filePatterns
411
+ if (plugin.filePatterns) {
412
+ for (const pattern of plugin.filePatterns) {
413
+ if (matchesGlob(file.name, pattern)) {
414
+ matched = true;
415
+ matchBonus = MATCH_BONUS_PATTERN;
416
+ break;
417
+ }
418
+ }
419
+ }
420
+
421
+ // Check canHandle (for plugins with only canHandle, or in addition to pattern match)
422
+ if (plugin.canHandle) {
423
+ const canHandleScore = plugin.canHandle(file);
424
+ if (canHandleScore > 0) {
425
+ if (!matched) {
426
+ // canHandle-only match: score IS the canHandle return value
427
+ matched = true;
428
+ matchBonus = canHandleScore;
429
+ } else {
430
+ // Pattern + canHandle: add canHandle score
431
+ matchBonus += canHandleScore;
432
+ }
433
+ } else if (!matched) {
434
+ continue;
435
+ }
436
+ }
437
+
438
+ if (matched) {
439
+ scored.push({ plugin, score: matchBonus + (plugin.priority ?? 0) });
440
+ }
441
+ }
442
+
443
+ // 4. If nothing matched, try default plugin
444
+ if (scored.length === 0) {
445
+ for (const plugin of this.plugins.values()) {
446
+ if (plugin.isDefault) {
447
+ scored.push({ plugin, score: 0 });
448
+ break;
449
+ }
450
+ }
451
+ }
452
+
453
+ // Sort by score descending, break ties by priority descending
454
+ scored.sort((a, b) => {
455
+ if (b.score !== a.score) return b.score - a.score;
456
+ return (b.plugin.priority ?? 0) - (a.plugin.priority ?? 0);
457
+ });
458
+
459
+ // Resolve to ResolvedViewer
460
+ return scored.map(({ plugin, score }) => ({
461
+ plugin,
462
+ Component: resolveComponent(plugin),
463
+ confidence: score,
464
+ }));
465
+ }
466
+
467
+ /**
468
+ * Rebuild plugin indexes from the plugin map.
469
+ */
470
+ private rebuildIndexes(): void {
471
+ this.extensionIndex.clear();
472
+ this.mimeTypeIndex.clear();
473
+ this.patternPlugins = [];
474
+
475
+ for (const plugin of this.plugins.values()) {
476
+ // Index by extension
477
+ if (plugin.extensions) {
478
+ for (const ext of plugin.extensions) {
479
+ const normalized = ext.toLowerCase().replace(/^\./, '');
480
+ const list = this.extensionIndex.get(normalized) || [];
481
+ list.push(plugin);
482
+ this.extensionIndex.set(normalized, list);
483
+ }
484
+ }
485
+
486
+ // Index by MIME type
487
+ if (plugin.mimeTypes) {
488
+ for (const mime of plugin.mimeTypes) {
489
+ const list = this.mimeTypeIndex.get(mime) || [];
490
+ list.push(plugin);
491
+ this.mimeTypeIndex.set(mime, list);
492
+ }
493
+ }
494
+
495
+ // Track plugins with patterns or canHandle
496
+ if (plugin.filePatterns || plugin.canHandle) {
497
+ this.patternPlugins.push(plugin);
498
+ }
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Sync a plugin into the legacy viewers/extensionMap storage.
504
+ */
505
+ private syncPluginToLegacy(plugin: FileViewerPlugin): void {
506
+ const config = pluginToViewerConfig(plugin);
507
+ this.viewers.set(config.id, config);
508
+
509
+ if (config.extensions) {
510
+ config.extensions.forEach(ext => {
511
+ const normalizedExt = ext.toLowerCase().replace(/^\./, '');
512
+ const existingViewers = this.extensionMap.get(normalizedExt) || [];
513
+
514
+ const insertIndex = existingViewers.findIndex(
515
+ v => (v.priority || 0) < (config.priority || 0)
516
+ );
517
+ if (insertIndex === -1) {
518
+ existingViewers.push(config);
519
+ } else {
520
+ existingViewers.splice(insertIndex, 0, config);
521
+ }
522
+
523
+ this.extensionMap.set(normalizedExt, existingViewers);
524
+ });
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Remove a plugin from legacy storage.
530
+ */
531
+ private removeLegacy(pluginId: string): void {
532
+ const config = this.viewers.get(pluginId);
533
+ if (!config) return;
534
+
535
+ this.viewers.delete(pluginId);
536
+
537
+ config.extensions.forEach(ext => {
538
+ const normalizedExt = ext.toLowerCase().replace(/^\./, '');
539
+ const viewers = this.extensionMap.get(normalizedExt) || [];
540
+ const filtered = viewers.filter(v => v.id !== pluginId);
541
+
542
+ if (filtered.length === 0) {
543
+ this.extensionMap.delete(normalizedExt);
544
+ } else {
545
+ this.extensionMap.set(normalizedExt, filtered);
546
+ }
547
+ });
548
+ }
549
+ }
550
+
551
+ export const globalViewerRegistry = new ViewerRegistry();
@@ -0,0 +1,144 @@
1
+ import React from 'react';
2
+ import type { ViewerProps } from './ViewerRegistry';
3
+
4
+ /**
5
+ * Plugin interface for file viewers.
6
+ * Extends the simple ViewerConfig with richer matching, lazy loading, and toolbar support.
7
+ */
8
+ export interface FileViewerPlugin {
9
+ /** Unique plugin ID, e.g., 'pdf-viewer', 'monaco-editor' */
10
+ id: string;
11
+
12
+ /** Human-readable name shown in "Open with..." menu */
13
+ name: string;
14
+
15
+ /** Optional description for UI tooltips */
16
+ description?: string;
17
+
18
+ /** Plugin capabilities */
19
+ capabilities: {
20
+ /** Can this plugin edit files (enables save flow)? */
21
+ canEdit?: boolean;
22
+ /** Can this plugin render in the inline preview pane (smaller viewport)? */
23
+ canPreview?: boolean;
24
+ /** Can this plugin render full-screen (the main viewer mode)? */
25
+ canFullscreen?: boolean;
26
+ };
27
+
28
+ // ---- MATCHING ----
29
+
30
+ /** File extensions this plugin handles (without dots), e.g., ['pdf', 'epub'] */
31
+ extensions?: string[];
32
+
33
+ /** MIME types this plugin handles, e.g., ['application/pdf'] */
34
+ mimeTypes?: string[];
35
+
36
+ /** Glob patterns for filenames, e.g., ['Dockerfile', 'Makefile', '.env*'] */
37
+ filePatterns?: string[];
38
+
39
+ /**
40
+ * Advanced match function for content-sniffing or complex logic.
41
+ * Called only if extension/MIME/pattern matching passes or if no
42
+ * static matchers are defined.
43
+ * Return a confidence score 0-100 (0 = cannot handle, 100 = perfect match).
44
+ */
45
+ canHandle?: (file: FileMatchContext) => number;
46
+
47
+ // ---- PRIORITY ----
48
+
49
+ /**
50
+ * Priority (higher = preferred). Default: 0.
51
+ * Built-in viewers use 10-20. Third-party plugins should use 50+
52
+ * to override built-in viewers, or negative to serve as fallbacks.
53
+ */
54
+ priority?: number;
55
+
56
+ // ---- COMPONENT LOADING ----
57
+
58
+ /**
59
+ * The viewer React component. Can be a concrete component or a lazy loader.
60
+ * When undefined, `load()` must be provided.
61
+ */
62
+ component?: React.ComponentType<ViewerProps>;
63
+
64
+ /**
65
+ * Dynamic loader that returns the viewer component.
66
+ * Used for code-splitting. The registry wraps this in React.lazy().
67
+ */
68
+ load?: () => Promise<{ default: React.ComponentType<ViewerProps> }>;
69
+
70
+ // ---- DEFAULT ----
71
+
72
+ /** If true, this plugin is used as the fallback for unrecognized file types */
73
+ isDefault?: boolean;
74
+
75
+ // ---- TOOLBAR EXTENSION ----
76
+
77
+ /**
78
+ * Declarative toolbar actions rendered by the ViewerHost.
79
+ * For dynamic toolbar items, plugins should use the `onToolbarExtras`
80
+ * callback on ViewerProps (already supported).
81
+ */
82
+ toolbarActions?: ToolbarAction[];
83
+
84
+ /**
85
+ * Keyboard shortcuts the plugin handles.
86
+ * The ViewerHost binds these when the plugin is active.
87
+ */
88
+ keyboardShortcuts?: KeyboardShortcut[];
89
+ }
90
+
91
+ /** Context passed to canHandle() and resolveViewer() for file matching */
92
+ export interface FileMatchContext {
93
+ /** Filename including extension */
94
+ name: string;
95
+ /** Full path */
96
+ path: string;
97
+ /** File extension without dot, lowercase */
98
+ extension: string;
99
+ /** MIME type if known */
100
+ mimeType?: string;
101
+ /** File size in bytes */
102
+ size?: number;
103
+ /**
104
+ * First bytes of content (for magic-number sniffing).
105
+ * Only available if the registry is configured to pre-read headers.
106
+ * Typically 512 bytes.
107
+ */
108
+ headerBytes?: ArrayBuffer;
109
+ }
110
+
111
+ /** Declarative toolbar action for ViewerHost */
112
+ export interface ToolbarAction {
113
+ id: string;
114
+ label: string;
115
+ icon?: React.ComponentType<{ className?: string }>;
116
+ /** Called when the action is triggered */
117
+ onClick: () => void;
118
+ /** Whether the action is currently enabled */
119
+ enabled?: boolean | (() => boolean);
120
+ /** Keyboard shortcut label for tooltip, e.g., "Ctrl+S" */
121
+ shortcutLabel?: string;
122
+ /** Position: 'left' | 'right'. Default: 'right' */
123
+ position?: 'left' | 'right';
124
+ }
125
+
126
+ /** Keyboard shortcut binding for ViewerHost */
127
+ export interface KeyboardShortcut {
128
+ /** Key combination, e.g., 'ctrl+s', 'escape', 'ctrl+shift+p' */
129
+ key: string;
130
+ /** Handler */
131
+ handler: () => void;
132
+ /** Only active when the viewer has focus? Default: true */
133
+ requireFocus?: boolean;
134
+ }
135
+
136
+ /** Result of resolving a viewer for a file */
137
+ export interface ResolvedViewer {
138
+ /** The matched plugin */
139
+ plugin: FileViewerPlugin;
140
+ /** The React component to render (resolved from component or load via React.lazy) */
141
+ Component: React.ComponentType<ViewerProps>;
142
+ /** Match confidence score. Extension match = 80, MIME = 70, pattern = 60, canHandle = dynamic */
143
+ confidence: number;
144
+ }