@imjp/writenex-astro 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 (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,344 @@
1
+ /**
2
+ * @fileoverview Styles for Custom Image Dialog
3
+ *
4
+ * Styles for the custom image dialog component that replaces
5
+ * MDXEditor's default image dialog.
6
+ */
7
+
8
+ /* ============================================================================
9
+ DIALOG BACKDROP & CONTAINER
10
+ ============================================================================ */
11
+
12
+ .wn-image-dialog-backdrop {
13
+ position: fixed;
14
+ inset: 0;
15
+ z-index: var(--wn-z-dialog);
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ background-color: var(--wn-backdrop);
20
+ backdrop-filter: blur(2px);
21
+ }
22
+
23
+ .wn-image-dialog {
24
+ width: 100%;
25
+ max-width: 400px;
26
+ margin: var(--wn-space-5);
27
+ background-color: var(--wn-zinc-900);
28
+ border: 1px solid var(--wn-zinc-700);
29
+ border-radius: var(--wn-radius-lg);
30
+ box-shadow:
31
+ 0 20px 25px -5px var(--wn-backdrop-light),
32
+ 0 8px 10px -6px var(--wn-overlay-light-10);
33
+ }
34
+
35
+ /* ============================================================================
36
+ DIALOG HEADER
37
+ ============================================================================ */
38
+
39
+ .wn-image-dialog-header {
40
+ display: flex;
41
+ align-items: center;
42
+ justify-content: space-between;
43
+ padding: var(--wn-space-5) var(--wn-space-5) var(--wn-space-4);
44
+ border-bottom: 1px solid var(--wn-zinc-800);
45
+ }
46
+
47
+ .wn-image-dialog-title {
48
+ font-size: var(--wn-font-md);
49
+ font-weight: 600;
50
+ color: var(--wn-zinc-100);
51
+ margin: 0;
52
+ }
53
+
54
+ .wn-image-dialog-close {
55
+ display: flex;
56
+ align-items: center;
57
+ justify-content: center;
58
+ width: var(--wn-icon-btn-md);
59
+ height: var(--wn-icon-btn-md);
60
+ padding: 0;
61
+ background: transparent;
62
+ border: none;
63
+ border-radius: var(--wn-radius-sm);
64
+ color: var(--wn-zinc-400);
65
+ cursor: pointer;
66
+ transition:
67
+ background-color var(--wn-transition-fast),
68
+ color var(--wn-transition-fast);
69
+ }
70
+
71
+ .wn-image-dialog-close:hover {
72
+ background-color: var(--wn-zinc-800);
73
+ color: var(--wn-zinc-100);
74
+ }
75
+
76
+ /* ============================================================================
77
+ TABS
78
+ ============================================================================ */
79
+
80
+ .wn-image-dialog-tabs {
81
+ display: flex;
82
+ margin: var(--wn-space-4) var(--wn-space-5) 0;
83
+ padding: var(--wn-space-1);
84
+ background-color: var(--wn-zinc-800);
85
+ border-radius: var(--wn-radius-md);
86
+ }
87
+
88
+ .wn-image-dialog-tab {
89
+ flex: 1;
90
+ display: flex;
91
+ align-items: center;
92
+ justify-content: center;
93
+ gap: var(--wn-space-3);
94
+ padding: var(--wn-space-3) var(--wn-space-4);
95
+ font-size: var(--wn-font-base);
96
+ font-weight: 500;
97
+ background: transparent;
98
+ border: none;
99
+ border-radius: var(--wn-radius-sm);
100
+ color: var(--wn-zinc-400);
101
+ cursor: pointer;
102
+ transition:
103
+ background-color var(--wn-transition-fast),
104
+ color var(--wn-transition-fast);
105
+ }
106
+
107
+ .wn-image-dialog-tab:hover {
108
+ color: var(--wn-zinc-200);
109
+ }
110
+
111
+ .wn-image-dialog-tab--active {
112
+ background-color: var(--wn-zinc-700);
113
+ color: var(--wn-zinc-100);
114
+ box-shadow: var(--wn-shadow-sm);
115
+ }
116
+
117
+ /* ============================================================================
118
+ CONTENT
119
+ ============================================================================ */
120
+
121
+ .wn-image-dialog-content {
122
+ display: flex;
123
+ flex-direction: column;
124
+ gap: var(--wn-space-4);
125
+ padding: var(--wn-space-5);
126
+ }
127
+
128
+ /* ============================================================================
129
+ DROPZONE
130
+ ============================================================================ */
131
+
132
+ .wn-image-dialog-dropzone {
133
+ display: flex;
134
+ flex-direction: column;
135
+ align-items: center;
136
+ justify-content: center;
137
+ gap: var(--wn-space-3);
138
+ padding: var(--wn-space-7);
139
+ border: 2px dashed var(--wn-zinc-700);
140
+ border-radius: var(--wn-radius-lg);
141
+ cursor: pointer;
142
+ transition:
143
+ background-color var(--wn-transition-fast),
144
+ border-color var(--wn-transition-fast);
145
+ }
146
+
147
+ .wn-image-dialog-dropzone:hover {
148
+ background-color: var(--wn-overlay-3);
149
+ border-color: var(--wn-zinc-600);
150
+ }
151
+
152
+ .wn-image-dialog-dropzone:focus {
153
+ outline: 2px solid var(--wn-brand-500);
154
+ outline-offset: 2px;
155
+ }
156
+
157
+ .wn-image-dialog-file-input {
158
+ display: none;
159
+ }
160
+
161
+ .wn-image-dialog-dropzone-icon {
162
+ color: var(--wn-zinc-500);
163
+ }
164
+
165
+ .wn-image-dialog-dropzone-text {
166
+ font-size: var(--wn-font-base);
167
+ color: var(--wn-zinc-400);
168
+ margin: 0;
169
+ }
170
+
171
+ .wn-image-dialog-file-info {
172
+ text-align: center;
173
+ }
174
+
175
+ .wn-image-dialog-file-name {
176
+ max-width: 200px;
177
+ overflow: hidden;
178
+ text-overflow: ellipsis;
179
+ white-space: nowrap;
180
+ font-size: var(--wn-font-base);
181
+ font-weight: 500;
182
+ color: var(--wn-zinc-100);
183
+ margin: 0;
184
+ }
185
+
186
+ .wn-image-dialog-file-size {
187
+ font-size: var(--wn-font-xs);
188
+ color: var(--wn-zinc-400);
189
+ margin: var(--wn-space-1) 0 0;
190
+ }
191
+
192
+ /* ============================================================================
193
+ FORM FIELDS
194
+ ============================================================================ */
195
+
196
+ .wn-image-dialog-field {
197
+ display: flex;
198
+ flex-direction: column;
199
+ gap: var(--wn-space-2);
200
+ }
201
+
202
+ .wn-image-dialog-label {
203
+ font-size: var(--wn-font-base);
204
+ font-weight: 500;
205
+ color: var(--wn-zinc-300);
206
+ }
207
+
208
+ .wn-image-dialog-input {
209
+ width: 100%;
210
+ padding: var(--wn-space-3) var(--wn-space-4);
211
+ font-size: var(--wn-font-base);
212
+ font-family: inherit;
213
+ background-color: var(--wn-zinc-800);
214
+ border: 1px solid var(--wn-zinc-700);
215
+ border-radius: var(--wn-radius-md);
216
+ color: var(--wn-zinc-100);
217
+ transition:
218
+ border-color var(--wn-transition-fast),
219
+ box-shadow var(--wn-transition-fast);
220
+ }
221
+
222
+ .wn-image-dialog-input::placeholder {
223
+ color: var(--wn-zinc-500);
224
+ }
225
+
226
+ .wn-image-dialog-input:focus {
227
+ outline: none;
228
+ border-color: var(--wn-brand-500);
229
+ box-shadow: 0 0 0 2px var(--wn-brand-alpha-20);
230
+ }
231
+
232
+ .wn-image-dialog-input--error {
233
+ border-color: var(--wn-error-500);
234
+ }
235
+
236
+ .wn-image-dialog-input--error:focus {
237
+ border-color: var(--wn-error-500);
238
+ box-shadow: 0 0 0 2px var(--wn-error-alpha-20);
239
+ }
240
+
241
+ .wn-image-dialog-error {
242
+ font-size: var(--wn-font-xs);
243
+ color: var(--wn-error-500);
244
+ margin: 0;
245
+ }
246
+
247
+ /* ============================================================================
248
+ FOOTER
249
+ ============================================================================ */
250
+
251
+ .wn-image-dialog-footer {
252
+ display: flex;
253
+ justify-content: flex-end;
254
+ gap: var(--wn-space-3);
255
+ padding: var(--wn-space-4) var(--wn-space-5) var(--wn-space-5);
256
+ border-top: 1px solid var(--wn-zinc-800);
257
+ }
258
+
259
+ /* ============================================================================
260
+ LIGHT MODE OVERRIDES
261
+ ============================================================================ */
262
+
263
+ .wn-light .wn-image-dialog {
264
+ background-color: white;
265
+ border-color: var(--wn-zinc-200);
266
+ }
267
+
268
+ .wn-light .wn-image-dialog-header {
269
+ border-bottom-color: var(--wn-zinc-200);
270
+ }
271
+
272
+ .wn-light .wn-image-dialog-title {
273
+ color: var(--wn-zinc-900);
274
+ }
275
+
276
+ .wn-light .wn-image-dialog-close {
277
+ color: var(--wn-zinc-500);
278
+ }
279
+
280
+ .wn-light .wn-image-dialog-close:hover {
281
+ background-color: var(--wn-zinc-100);
282
+ color: var(--wn-zinc-900);
283
+ }
284
+
285
+ .wn-light .wn-image-dialog-tabs {
286
+ background-color: var(--wn-zinc-100);
287
+ }
288
+
289
+ .wn-light .wn-image-dialog-tab {
290
+ color: var(--wn-zinc-500);
291
+ }
292
+
293
+ .wn-light .wn-image-dialog-tab:hover {
294
+ color: var(--wn-zinc-700);
295
+ }
296
+
297
+ .wn-light .wn-image-dialog-tab--active {
298
+ background-color: white;
299
+ color: var(--wn-zinc-900);
300
+ box-shadow: var(--wn-shadow-sm);
301
+ }
302
+
303
+ .wn-light .wn-image-dialog-dropzone {
304
+ border-color: var(--wn-zinc-300);
305
+ }
306
+
307
+ .wn-light .wn-image-dialog-dropzone:hover {
308
+ background-color: var(--wn-zinc-50);
309
+ border-color: var(--wn-zinc-400);
310
+ }
311
+
312
+ .wn-light .wn-image-dialog-dropzone-icon {
313
+ color: var(--wn-zinc-400);
314
+ }
315
+
316
+ .wn-light .wn-image-dialog-dropzone-text {
317
+ color: var(--wn-zinc-500);
318
+ }
319
+
320
+ .wn-light .wn-image-dialog-file-name {
321
+ color: var(--wn-zinc-900);
322
+ }
323
+
324
+ .wn-light .wn-image-dialog-file-size {
325
+ color: var(--wn-zinc-500);
326
+ }
327
+
328
+ .wn-light .wn-image-dialog-label {
329
+ color: var(--wn-zinc-700);
330
+ }
331
+
332
+ .wn-light .wn-image-dialog-input {
333
+ background-color: white;
334
+ border-color: var(--wn-zinc-300);
335
+ color: var(--wn-zinc-900);
336
+ }
337
+
338
+ .wn-light .wn-image-dialog-input::placeholder {
339
+ color: var(--wn-zinc-400);
340
+ }
341
+
342
+ .wn-light .wn-image-dialog-footer {
343
+ border-top-color: var(--wn-zinc-200);
344
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * @fileoverview Custom Image Dialog Component for @writenex/astro
3
+ *
4
+ * This component provides a custom dialog for inserting and editing images
5
+ * in the MDXEditor. It replaces the default MDXEditor image dialog with
6
+ * a styled version that matches the Writenex design system.
7
+ * Includes focus trap for accessibility compliance.
8
+ *
9
+ * ## Features:
10
+ * - Tab interface for switching between upload and URL modes
11
+ * - Drag-and-drop ready file upload zone
12
+ * - Alt text and title fields for accessibility
13
+ * - URL validation with error feedback
14
+ * - Works with MDXEditor's image plugin system
15
+ * - Focus trap for keyboard accessibility
16
+ *
17
+ * @module @writenex/astro/client/components/Editor/ImageDialog
18
+ */
19
+
20
+ import { useState, useRef, useEffect, useCallback } from "react";
21
+ import { usePublisher, useCellValue } from "@mdxeditor/editor";
22
+ import {
23
+ insertImage$,
24
+ closeImageDialog$,
25
+ imageDialogState$,
26
+ saveImage$,
27
+ } from "@mdxeditor/editor";
28
+ import { Image as ImageIcon, Link as LinkIcon, Upload, X } from "lucide-react";
29
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
30
+ import "./ImageDialog.css";
31
+
32
+ /**
33
+ * Validates if a string is a valid URL
34
+ */
35
+ function isValidUrl(url: string): boolean {
36
+ if (!url || url.trim() === "") return false;
37
+ try {
38
+ const parsed = new URL(url);
39
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Custom Image Dialog component for MDXEditor.
47
+ *
48
+ * This component is passed to MDXEditor's imagePlugin as a custom dialog.
49
+ * It handles both inserting new images and editing existing ones.
50
+ *
51
+ * @component
52
+ * @example
53
+ * ```tsx
54
+ * // Used in MDXEditor plugin configuration
55
+ * imagePlugin({
56
+ * imageUploadHandler: handleUpload,
57
+ * imagePreviewHandler: handlePreview,
58
+ * ImageDialog: ImageDialog,
59
+ * })
60
+ * ```
61
+ */
62
+ export function ImageDialog(): React.ReactElement {
63
+ const insertImage = usePublisher(insertImage$);
64
+ const saveImage = usePublisher(saveImage$);
65
+ const closeImageDialog = usePublisher(closeImageDialog$);
66
+ const state = useCellValue(imageDialogState$);
67
+
68
+ const [mode, setMode] = useState<"upload" | "url">("upload");
69
+ const [src, setSrc] = useState("");
70
+ const [file, setFile] = useState<File | null>(null);
71
+ const [altText, setAltText] = useState("");
72
+ const [title, setTitle] = useState("");
73
+ const [prevType, setPrevType] = useState(state.type);
74
+ const [isUrlValid, setIsUrlValid] = useState(true);
75
+
76
+ const fileInputRef = useRef<HTMLInputElement>(null);
77
+ const triggerRef = useRef<HTMLElement | null>(null);
78
+
79
+ // Store the trigger element when dialog opens
80
+ useEffect(() => {
81
+ if (state.type !== "inactive") {
82
+ triggerRef.current = document.activeElement as HTMLElement;
83
+ }
84
+ }, [state.type]);
85
+
86
+ // Focus trap for accessibility
87
+ const { containerRef } = useFocusTrap({
88
+ enabled: state.type !== "inactive",
89
+ onEscape: closeImageDialog,
90
+ returnFocusTo: triggerRef.current,
91
+ });
92
+
93
+ // Reset or populate form when state changes
94
+ if (state.type !== prevType) {
95
+ setPrevType(state.type);
96
+ if (state.type === "editing") {
97
+ setMode(state.initialValues.src ? "url" : "upload");
98
+ setSrc(state.initialValues.src || "");
99
+ setAltText(state.initialValues.altText || "");
100
+ setTitle(state.initialValues.title || "");
101
+ setFile(null);
102
+ setIsUrlValid(true);
103
+ } else if (state.type === "new") {
104
+ setMode("upload");
105
+ setSrc("");
106
+ setAltText("");
107
+ setTitle("");
108
+ setFile(null);
109
+ setIsUrlValid(true);
110
+ }
111
+ }
112
+
113
+ // Focus first input when dialog opens (useFocusTrap handles escape key)
114
+ useEffect(() => {
115
+ if (state.type !== "inactive" && containerRef.current) {
116
+ const firstInput = containerRef.current.querySelector("input, button");
117
+ if (firstInput instanceof HTMLElement) {
118
+ setTimeout(() => firstInput.focus(), 50);
119
+ }
120
+ }
121
+ }, [state.type, containerRef]);
122
+
123
+ const handleSrcChange = useCallback(
124
+ (e: React.ChangeEvent<HTMLInputElement>) => {
125
+ const newSrc = e.target.value;
126
+ setSrc(newSrc);
127
+ setIsUrlValid(newSrc === "" || isValidUrl(newSrc));
128
+ },
129
+ []
130
+ );
131
+
132
+ const handleSave = useCallback(() => {
133
+ if (mode === "url" && !isValidUrl(src)) {
134
+ setIsUrlValid(false);
135
+ return;
136
+ }
137
+
138
+ if (state.type === "editing") {
139
+ const payload: {
140
+ altText: string;
141
+ title: string;
142
+ file?: FileList;
143
+ src?: string;
144
+ } = {
145
+ altText,
146
+ title,
147
+ };
148
+
149
+ if (mode === "upload" && file) {
150
+ const dt = new DataTransfer();
151
+ dt.items.add(file);
152
+ payload.file = dt.files;
153
+ } else if (mode === "url" && src) {
154
+ payload.src = src;
155
+ }
156
+
157
+ saveImage(payload);
158
+ } else {
159
+ if (mode === "upload" && file) {
160
+ insertImage({ file, altText, title });
161
+ } else if (mode === "url" && src) {
162
+ insertImage({ src, altText, title });
163
+ }
164
+ }
165
+ closeImageDialog();
166
+ }, [
167
+ mode,
168
+ src,
169
+ file,
170
+ altText,
171
+ title,
172
+ state.type,
173
+ insertImage,
174
+ saveImage,
175
+ closeImageDialog,
176
+ ]);
177
+
178
+ const handleFileChange = useCallback(
179
+ (e: React.ChangeEvent<HTMLInputElement>) => {
180
+ if (e.target.files?.[0]) {
181
+ setFile(e.target.files[0]);
182
+ }
183
+ },
184
+ []
185
+ );
186
+
187
+ const handleBackdropClick = useCallback(
188
+ (e: React.MouseEvent) => {
189
+ if (e.target === e.currentTarget) {
190
+ closeImageDialog();
191
+ }
192
+ },
193
+ [closeImageDialog]
194
+ );
195
+
196
+ const isOpen = state.type !== "inactive";
197
+ const canSave = mode === "upload" ? !!file : !!src && isUrlValid;
198
+
199
+ if (!isOpen) return <></>;
200
+
201
+ return (
202
+ <div
203
+ className="wn-image-dialog-backdrop"
204
+ onClick={handleBackdropClick}
205
+ role="dialog"
206
+ aria-modal="true"
207
+ aria-labelledby="image-dialog-title"
208
+ >
209
+ <div className="wn-image-dialog" ref={containerRef}>
210
+ {/* Header */}
211
+ <div className="wn-image-dialog-header">
212
+ <h2 id="image-dialog-title" className="wn-image-dialog-title">
213
+ {state.type === "editing" ? "Edit Image" : "Insert Image"}
214
+ </h2>
215
+ <button
216
+ className="wn-image-dialog-close"
217
+ onClick={() => closeImageDialog()}
218
+ aria-label="Close dialog"
219
+ >
220
+ <X size={18} />
221
+ </button>
222
+ </div>
223
+
224
+ {/* Tabs */}
225
+ <div className="wn-image-dialog-tabs">
226
+ <button
227
+ className={`wn-image-dialog-tab ${mode === "upload" ? "wn-image-dialog-tab--active" : ""}`}
228
+ onClick={() => setMode("upload")}
229
+ type="button"
230
+ >
231
+ <Upload size={16} />
232
+ Upload
233
+ </button>
234
+ <button
235
+ className={`wn-image-dialog-tab ${mode === "url" ? "wn-image-dialog-tab--active" : ""}`}
236
+ onClick={() => setMode("url")}
237
+ type="button"
238
+ >
239
+ <LinkIcon size={16} />
240
+ URL
241
+ </button>
242
+ </div>
243
+
244
+ {/* Content */}
245
+ <div className="wn-image-dialog-content">
246
+ {mode === "upload" ? (
247
+ <div
248
+ className="wn-image-dialog-dropzone"
249
+ onClick={() => fileInputRef.current?.click()}
250
+ role="button"
251
+ tabIndex={0}
252
+ onKeyDown={(e) => {
253
+ if (e.key === "Enter" || e.key === " ") {
254
+ fileInputRef.current?.click();
255
+ }
256
+ }}
257
+ >
258
+ <input
259
+ type="file"
260
+ ref={fileInputRef}
261
+ className="wn-image-dialog-file-input"
262
+ accept="image/*"
263
+ onChange={handleFileChange}
264
+ />
265
+ {file ? (
266
+ <div className="wn-image-dialog-file-info">
267
+ <p className="wn-image-dialog-file-name">{file.name}</p>
268
+ <p className="wn-image-dialog-file-size">
269
+ {(file.size / 1024).toFixed(1)} KB
270
+ </p>
271
+ </div>
272
+ ) : (
273
+ <>
274
+ <ImageIcon
275
+ size={32}
276
+ className="wn-image-dialog-dropzone-icon"
277
+ />
278
+ <p className="wn-image-dialog-dropzone-text">
279
+ Click to select an image
280
+ </p>
281
+ </>
282
+ )}
283
+ </div>
284
+ ) : (
285
+ <div className="wn-image-dialog-field">
286
+ <label
287
+ className="wn-image-dialog-label"
288
+ htmlFor="image-url-input"
289
+ >
290
+ Image URL
291
+ </label>
292
+ <input
293
+ id="image-url-input"
294
+ type="text"
295
+ className={`wn-image-dialog-input ${!isUrlValid ? "wn-image-dialog-input--error" : ""}`}
296
+ value={src}
297
+ onChange={handleSrcChange}
298
+ placeholder="https://example.com/image.png"
299
+ aria-invalid={!isUrlValid}
300
+ aria-describedby={!isUrlValid ? "image-url-error" : undefined}
301
+ />
302
+ {!isUrlValid && (
303
+ <p
304
+ id="image-url-error"
305
+ className="wn-image-dialog-error"
306
+ role="alert"
307
+ >
308
+ Please enter a valid image URL
309
+ </p>
310
+ )}
311
+ </div>
312
+ )}
313
+
314
+ <div className="wn-image-dialog-field">
315
+ <label className="wn-image-dialog-label" htmlFor="image-alt-input">
316
+ Alt Text
317
+ </label>
318
+ <input
319
+ id="image-alt-input"
320
+ type="text"
321
+ className="wn-image-dialog-input"
322
+ value={altText}
323
+ onChange={(e) => setAltText(e.target.value)}
324
+ placeholder="Description for accessibility"
325
+ />
326
+ </div>
327
+
328
+ <div className="wn-image-dialog-field">
329
+ <label
330
+ className="wn-image-dialog-label"
331
+ htmlFor="image-title-input"
332
+ >
333
+ Title (Optional)
334
+ </label>
335
+ <input
336
+ id="image-title-input"
337
+ type="text"
338
+ className="wn-image-dialog-input"
339
+ value={title}
340
+ onChange={(e) => setTitle(e.target.value)}
341
+ placeholder="Hover text"
342
+ />
343
+ </div>
344
+ </div>
345
+
346
+ {/* Footer */}
347
+ <div className="wn-image-dialog-footer">
348
+ <button
349
+ className="wn-btn-secondary"
350
+ onClick={() => closeImageDialog()}
351
+ type="button"
352
+ >
353
+ Cancel
354
+ </button>
355
+ <button
356
+ className="wn-btn-primary"
357
+ onClick={handleSave}
358
+ disabled={!canSave}
359
+ type="button"
360
+ >
361
+ Save
362
+ </button>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ );
367
+ }