@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,326 @@
1
+ /**
2
+ * @fileoverview Styles for Custom Link Dialog
3
+ *
4
+ * Styles for the custom link dialog component that replaces
5
+ * MDXEditor's default link dialog. Includes both preview popover
6
+ * and edit modal styles.
7
+ */
8
+
9
+ /* ============================================================================
10
+ LINK PREVIEW POPOVER
11
+ ============================================================================ */
12
+
13
+ .wn-link-preview {
14
+ position: fixed;
15
+ z-index: var(--wn-z-dialog);
16
+ display: flex;
17
+ align-items: center;
18
+ gap: var(--wn-space-1);
19
+ padding: var(--wn-space-2);
20
+ background-color: var(--wn-zinc-900);
21
+ border: 1px solid var(--wn-zinc-700);
22
+ border-radius: var(--wn-radius-lg);
23
+ box-shadow:
24
+ 0 10px 15px -3px var(--wn-backdrop-light),
25
+ 0 4px 6px -4px var(--wn-overlay-light-10);
26
+ animation: wn-fade-in 0.1s ease-out;
27
+ }
28
+
29
+ @keyframes wn-fade-in {
30
+ from {
31
+ opacity: 0;
32
+ transform: scale(0.95);
33
+ }
34
+ to {
35
+ opacity: 1;
36
+ transform: scale(1);
37
+ }
38
+ }
39
+
40
+ .wn-link-preview-url {
41
+ max-width: 250px;
42
+ overflow: hidden;
43
+ text-overflow: ellipsis;
44
+ white-space: nowrap;
45
+ padding: 0 var(--wn-space-3);
46
+ font-size: var(--wn-font-base);
47
+ font-weight: 500;
48
+ color: var(--wn-brand-400);
49
+ text-decoration: none;
50
+ }
51
+
52
+ .wn-link-preview-url:hover {
53
+ text-decoration: underline;
54
+ }
55
+
56
+ .wn-link-preview-divider {
57
+ width: 1px;
58
+ height: var(--wn-space-5);
59
+ margin: 0 var(--wn-space-1);
60
+ background-color: var(--wn-zinc-700);
61
+ }
62
+
63
+ .wn-link-preview-btn {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ width: var(--wn-icon-btn-md);
68
+ height: var(--wn-icon-btn-md);
69
+ padding: 0;
70
+ background: transparent;
71
+ border: none;
72
+ border-radius: var(--wn-radius-sm);
73
+ color: var(--wn-zinc-400);
74
+ cursor: pointer;
75
+ transition:
76
+ background-color var(--wn-transition-fast),
77
+ color var(--wn-transition-fast);
78
+ }
79
+
80
+ .wn-link-preview-btn:hover {
81
+ background-color: var(--wn-overlay-10);
82
+ color: var(--wn-zinc-100);
83
+ }
84
+
85
+ .wn-link-preview-btn--danger:hover {
86
+ background-color: var(--wn-error-alpha-15);
87
+ color: var(--wn-error-400);
88
+ }
89
+
90
+ /* ============================================================================
91
+ LINK DIALOG MODAL
92
+ ============================================================================ */
93
+
94
+ .wn-link-dialog-backdrop {
95
+ position: fixed;
96
+ inset: 0;
97
+ z-index: var(--wn-z-dialog);
98
+ display: flex;
99
+ align-items: center;
100
+ justify-content: center;
101
+ background-color: var(--wn-backdrop);
102
+ backdrop-filter: blur(2px);
103
+ }
104
+
105
+ .wn-link-dialog {
106
+ width: 100%;
107
+ max-width: 400px;
108
+ margin: var(--wn-space-5);
109
+ background-color: var(--wn-zinc-900);
110
+ border: 1px solid var(--wn-zinc-700);
111
+ border-radius: var(--wn-radius-lg);
112
+ box-shadow:
113
+ 0 20px 25px -5px var(--wn-backdrop-light),
114
+ 0 8px 10px -6px var(--wn-overlay-light-10);
115
+ }
116
+
117
+ /* ============================================================================
118
+ DIALOG HEADER
119
+ ============================================================================ */
120
+
121
+ .wn-link-dialog-header {
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: space-between;
125
+ padding: var(--wn-space-5) var(--wn-space-5) var(--wn-space-4);
126
+ border-bottom: 1px solid var(--wn-zinc-800);
127
+ }
128
+
129
+ .wn-link-dialog-title {
130
+ font-size: var(--wn-font-md);
131
+ font-weight: 600;
132
+ color: var(--wn-zinc-100);
133
+ margin: 0;
134
+ }
135
+
136
+ .wn-link-dialog-close {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: center;
140
+ width: var(--wn-icon-btn-md);
141
+ height: var(--wn-icon-btn-md);
142
+ padding: 0;
143
+ background: transparent;
144
+ border: none;
145
+ border-radius: var(--wn-radius-sm);
146
+ color: var(--wn-zinc-400);
147
+ cursor: pointer;
148
+ transition:
149
+ background-color var(--wn-transition-fast),
150
+ color var(--wn-transition-fast);
151
+ }
152
+
153
+ .wn-link-dialog-close:hover {
154
+ background-color: var(--wn-zinc-800);
155
+ color: var(--wn-zinc-100);
156
+ }
157
+
158
+ /* ============================================================================
159
+ DIALOG CONTENT
160
+ ============================================================================ */
161
+
162
+ .wn-link-dialog-content {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: var(--wn-space-5);
166
+ padding: var(--wn-space-5);
167
+ }
168
+
169
+ /* ============================================================================
170
+ FORM FIELDS
171
+ ============================================================================ */
172
+
173
+ .wn-link-dialog-field {
174
+ display: flex;
175
+ flex-direction: column;
176
+ gap: var(--wn-space-2);
177
+ }
178
+
179
+ .wn-link-dialog-label {
180
+ font-size: var(--wn-font-base);
181
+ font-weight: 500;
182
+ color: var(--wn-zinc-300);
183
+ }
184
+
185
+ .wn-link-dialog-input-wrapper {
186
+ position: relative;
187
+ }
188
+
189
+ .wn-link-dialog-input-icon {
190
+ position: absolute;
191
+ top: 50%;
192
+ left: var(--wn-space-4);
193
+ transform: translateY(-50%);
194
+ color: var(--wn-zinc-500);
195
+ pointer-events: none;
196
+ }
197
+
198
+ .wn-link-dialog-input {
199
+ width: 100%;
200
+ padding: var(--wn-space-3) var(--wn-space-4);
201
+ font-size: var(--wn-font-base);
202
+ font-family: inherit;
203
+ background-color: var(--wn-zinc-800);
204
+ border: 1px solid var(--wn-zinc-700);
205
+ border-radius: var(--wn-radius-md);
206
+ color: var(--wn-zinc-100);
207
+ transition:
208
+ border-color var(--wn-transition-fast),
209
+ box-shadow var(--wn-transition-fast);
210
+ }
211
+
212
+ .wn-link-dialog-input--with-icon {
213
+ padding-left: 2.25rem;
214
+ }
215
+
216
+ .wn-link-dialog-input::placeholder {
217
+ color: var(--wn-zinc-500);
218
+ }
219
+
220
+ .wn-link-dialog-input:focus {
221
+ outline: none;
222
+ border-color: var(--wn-brand-500);
223
+ box-shadow: 0 0 0 2px var(--wn-brand-alpha-20);
224
+ }
225
+
226
+ .wn-link-dialog-input--error {
227
+ border-color: var(--wn-error-500);
228
+ }
229
+
230
+ .wn-link-dialog-input--error:focus {
231
+ border-color: var(--wn-error-500);
232
+ box-shadow: 0 0 0 2px var(--wn-error-alpha-20);
233
+ }
234
+
235
+ .wn-link-dialog-error {
236
+ font-size: var(--wn-font-xs);
237
+ color: var(--wn-error-500);
238
+ margin: 0;
239
+ }
240
+
241
+ /* ============================================================================
242
+ DIALOG FOOTER
243
+ ============================================================================ */
244
+
245
+ .wn-link-dialog-footer {
246
+ display: flex;
247
+ justify-content: flex-end;
248
+ gap: var(--wn-space-3);
249
+ padding: var(--wn-space-4) var(--wn-space-5) var(--wn-space-5);
250
+ border-top: 1px solid var(--wn-zinc-800);
251
+ }
252
+
253
+ /* ============================================================================
254
+ LIGHT MODE OVERRIDES
255
+ ============================================================================ */
256
+
257
+ .wn-light .wn-link-preview {
258
+ background-color: white;
259
+ border-color: var(--wn-zinc-200);
260
+ }
261
+
262
+ .wn-light .wn-link-preview-url {
263
+ color: var(--wn-brand-600);
264
+ }
265
+
266
+ .wn-light .wn-link-preview-divider {
267
+ background-color: var(--wn-zinc-200);
268
+ }
269
+
270
+ .wn-light .wn-link-preview-btn {
271
+ color: var(--wn-zinc-500);
272
+ }
273
+
274
+ .wn-light .wn-link-preview-btn:hover {
275
+ background-color: var(--wn-zinc-100);
276
+ color: var(--wn-zinc-900);
277
+ }
278
+
279
+ .wn-light .wn-link-preview-btn--danger:hover {
280
+ background-color: var(--wn-error-alpha-10);
281
+ color: var(--wn-error-600);
282
+ }
283
+
284
+ .wn-light .wn-link-dialog {
285
+ background-color: white;
286
+ border-color: var(--wn-zinc-200);
287
+ }
288
+
289
+ .wn-light .wn-link-dialog-header {
290
+ border-bottom-color: var(--wn-zinc-200);
291
+ }
292
+
293
+ .wn-light .wn-link-dialog-title {
294
+ color: var(--wn-zinc-900);
295
+ }
296
+
297
+ .wn-light .wn-link-dialog-close {
298
+ color: var(--wn-zinc-500);
299
+ }
300
+
301
+ .wn-light .wn-link-dialog-close:hover {
302
+ background-color: var(--wn-zinc-100);
303
+ color: var(--wn-zinc-900);
304
+ }
305
+
306
+ .wn-light .wn-link-dialog-label {
307
+ color: var(--wn-zinc-700);
308
+ }
309
+
310
+ .wn-light .wn-link-dialog-input-icon {
311
+ color: var(--wn-zinc-400);
312
+ }
313
+
314
+ .wn-light .wn-link-dialog-input {
315
+ background-color: white;
316
+ border-color: var(--wn-zinc-300);
317
+ color: var(--wn-zinc-900);
318
+ }
319
+
320
+ .wn-light .wn-link-dialog-input::placeholder {
321
+ color: var(--wn-zinc-400);
322
+ }
323
+
324
+ .wn-light .wn-link-dialog-footer {
325
+ border-top-color: var(--wn-zinc-200);
326
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * @fileoverview Custom Link Dialog Component for @writenex/astro
3
+ *
4
+ * This component provides a custom dialog for inserting and editing links
5
+ * in the MDXEditor. It has two modes:
6
+ * 1. Preview mode: A floating popover showing the link URL with quick actions
7
+ * 2. Edit mode: A modal dialog for inserting new links or editing existing ones
8
+ *
9
+ * ## Features:
10
+ * - Floating preview popover for existing links (click to see URL)
11
+ * - Copy, edit, and remove actions in preview mode
12
+ * - Modal dialog for new/edit with URL validation
13
+ * - Optional title field for hover text
14
+ * - Works with MDXEditor's link plugin system
15
+ * - Focus trap for keyboard accessibility
16
+ *
17
+ * @module @writenex/astro/client/components/Editor/LinkDialog
18
+ */
19
+
20
+ import { useState, useCallback, useEffect, useRef } from "react";
21
+ import { usePublisher, useCellValue } from "@mdxeditor/editor";
22
+ import {
23
+ linkDialogState$,
24
+ cancelLinkEdit$,
25
+ updateLink$,
26
+ switchFromPreviewToLinkEdit$,
27
+ removeLink$,
28
+ } from "@mdxeditor/editor";
29
+ import { Link as LinkIcon, Trash2, Edit2, Copy, X } from "lucide-react";
30
+ import { useFocusTrap } from "../../hooks/useFocusTrap";
31
+ import "./LinkDialog.css";
32
+
33
+ /**
34
+ * Link dialog state when showing a preview of an existing link.
35
+ */
36
+ interface LinkDialogStatePreview {
37
+ type: "preview";
38
+ url: string;
39
+ title: string;
40
+ rectangle: DOMRect;
41
+ }
42
+
43
+ /**
44
+ * Link dialog state when editing or inserting a link.
45
+ */
46
+ interface LinkDialogStateEdit {
47
+ type: "edit";
48
+ url: string;
49
+ title: string;
50
+ rectangle: DOMRect;
51
+ }
52
+
53
+ /**
54
+ * Link dialog state when the dialog is closed.
55
+ */
56
+ interface LinkDialogStateInactive {
57
+ type: "inactive";
58
+ }
59
+
60
+ /**
61
+ * Union type for all possible link dialog states.
62
+ */
63
+ type LinkDialogState =
64
+ | LinkDialogStatePreview
65
+ | LinkDialogStateEdit
66
+ | LinkDialogStateInactive;
67
+
68
+ /**
69
+ * Validates if a string is a valid URL
70
+ */
71
+ function isValidUrl(url: string): boolean {
72
+ if (!url || url.trim() === "") return false;
73
+ try {
74
+ const parsed = new URL(url);
75
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Custom Link Dialog component for MDXEditor.
83
+ *
84
+ * This component is passed to MDXEditor's linkDialogPlugin as a custom dialog.
85
+ * It renders differently based on state:
86
+ *
87
+ * - Preview: Floating popover positioned near the link with URL preview,
88
+ * copy button, edit button, and remove button
89
+ * - Edit: Modal dialog with URL input, title input, and save/cancel buttons
90
+ * - Inactive: Returns null (nothing rendered)
91
+ *
92
+ * @component
93
+ * @example
94
+ * ```tsx
95
+ * // Used in MDXEditor plugin configuration
96
+ * linkDialogPlugin({
97
+ * LinkDialog: LinkDialog,
98
+ * })
99
+ * ```
100
+ */
101
+ export function LinkDialog(): React.ReactElement {
102
+ const state = useCellValue(linkDialogState$) as LinkDialogState;
103
+ const cancelLinkEdit = usePublisher(cancelLinkEdit$);
104
+ const updateLink = usePublisher(updateLink$);
105
+ const switchFromPreviewToLinkEdit = usePublisher(
106
+ switchFromPreviewToLinkEdit$
107
+ );
108
+ const removeLink = usePublisher(removeLink$);
109
+
110
+ const [url, setUrl] = useState("");
111
+ const [title, setTitle] = useState("");
112
+ const [prevType, setPrevType] = useState(state.type);
113
+ const [isUrlValid, setIsUrlValid] = useState(true);
114
+ const [isEditMode, setIsEditMode] = useState(false);
115
+ const [copySuccess, setCopySuccess] = useState(false);
116
+
117
+ const triggerRef = useRef<HTMLElement | null>(null);
118
+
119
+ // Store the trigger element when dialog opens in edit mode
120
+ useEffect(() => {
121
+ if (state.type === "edit") {
122
+ triggerRef.current = document.activeElement as HTMLElement;
123
+ }
124
+ }, [state.type]);
125
+
126
+ // Focus trap for accessibility (only for edit mode, not preview)
127
+ const { containerRef } = useFocusTrap({
128
+ enabled: state.type === "edit",
129
+ onEscape: cancelLinkEdit,
130
+ returnFocusTo: triggerRef.current,
131
+ });
132
+
133
+ // Reset or populate form when state changes
134
+ if (state.type !== prevType) {
135
+ setPrevType(state.type);
136
+ if (state.type === "edit") {
137
+ setUrl(state.url);
138
+ setTitle(state.title);
139
+ setIsUrlValid(true);
140
+ setIsEditMode(!!state.url);
141
+ }
142
+ }
143
+
144
+ // Focus first input when edit dialog opens (useFocusTrap handles escape key)
145
+ useEffect(() => {
146
+ if (state.type === "edit" && containerRef.current) {
147
+ const firstInput = containerRef.current.querySelector("input");
148
+ if (firstInput) {
149
+ setTimeout(() => firstInput.focus(), 50);
150
+ }
151
+ }
152
+ }, [state.type, containerRef]);
153
+
154
+ const handleUrlChange = useCallback(
155
+ (e: React.ChangeEvent<HTMLInputElement>) => {
156
+ const newUrl = e.target.value;
157
+ setUrl(newUrl);
158
+ setIsUrlValid(newUrl === "" || isValidUrl(newUrl));
159
+ },
160
+ []
161
+ );
162
+
163
+ const handleSave = useCallback(() => {
164
+ if (!isValidUrl(url)) {
165
+ setIsUrlValid(false);
166
+ return;
167
+ }
168
+ updateLink({ url, title, text: undefined });
169
+ }, [updateLink, url, title]);
170
+
171
+ const handleCopy = useCallback(() => {
172
+ if (state.type === "preview") {
173
+ navigator.clipboard.writeText(state.url).then(() => {
174
+ setCopySuccess(true);
175
+ setTimeout(() => setCopySuccess(false), 1500);
176
+ });
177
+ }
178
+ }, [state]);
179
+
180
+ const handleBackdropClick = useCallback(
181
+ (e: React.MouseEvent) => {
182
+ if (e.target === e.currentTarget) {
183
+ cancelLinkEdit();
184
+ }
185
+ },
186
+ [cancelLinkEdit]
187
+ );
188
+
189
+ if (state.type === "inactive") {
190
+ return <></>;
191
+ }
192
+
193
+ // PREVIEW MODE: Render as a floating popover
194
+ if (state.type === "preview") {
195
+ return (
196
+ <div
197
+ className="wn-link-preview"
198
+ style={{
199
+ top: (state.rectangle?.top ?? 0) + (state.rectangle?.height ?? 0) + 8,
200
+ left: state.rectangle?.left ?? 0,
201
+ }}
202
+ >
203
+ <a
204
+ href={state.url}
205
+ target="_blank"
206
+ rel="noopener noreferrer"
207
+ className="wn-link-preview-url"
208
+ title={state.url}
209
+ >
210
+ {state.url}
211
+ </a>
212
+
213
+ <div className="wn-link-preview-divider" />
214
+
215
+ <button
216
+ onClick={handleCopy}
217
+ className="wn-link-preview-btn"
218
+ title={copySuccess ? "Copied!" : "Copy URL"}
219
+ >
220
+ <Copy size={16} />
221
+ </button>
222
+
223
+ <button
224
+ onClick={() => switchFromPreviewToLinkEdit()}
225
+ className="wn-link-preview-btn"
226
+ title="Edit Link"
227
+ >
228
+ <Edit2 size={16} />
229
+ </button>
230
+
231
+ <button
232
+ onClick={() => removeLink()}
233
+ className="wn-link-preview-btn wn-link-preview-btn--danger"
234
+ title="Remove Link"
235
+ >
236
+ <Trash2 size={16} />
237
+ </button>
238
+ </div>
239
+ );
240
+ }
241
+
242
+ // EDIT MODE: Render as a Modal Dialog
243
+ return (
244
+ <div
245
+ className="wn-link-dialog-backdrop"
246
+ onClick={handleBackdropClick}
247
+ role="dialog"
248
+ aria-modal="true"
249
+ aria-labelledby="link-dialog-title"
250
+ >
251
+ <div className="wn-link-dialog" ref={containerRef}>
252
+ {/* Header */}
253
+ <div className="wn-link-dialog-header">
254
+ <h2 id="link-dialog-title" className="wn-link-dialog-title">
255
+ {isEditMode ? "Edit Link" : "Insert Link"}
256
+ </h2>
257
+ <button
258
+ className="wn-link-dialog-close"
259
+ onClick={() => cancelLinkEdit()}
260
+ aria-label="Close dialog"
261
+ >
262
+ <X size={18} />
263
+ </button>
264
+ </div>
265
+
266
+ {/* Content */}
267
+ <div className="wn-link-dialog-content">
268
+ <div className="wn-link-dialog-field">
269
+ <label className="wn-link-dialog-label" htmlFor="link-url-input">
270
+ URL
271
+ </label>
272
+ <div className="wn-link-dialog-input-wrapper">
273
+ <LinkIcon size={16} className="wn-link-dialog-input-icon" />
274
+ <input
275
+ id="link-url-input"
276
+ type="text"
277
+ className={`wn-link-dialog-input wn-link-dialog-input--with-icon ${!isUrlValid ? "wn-link-dialog-input--error" : ""}`}
278
+ value={url}
279
+ onChange={handleUrlChange}
280
+ placeholder="https://example.com"
281
+ aria-invalid={!isUrlValid}
282
+ aria-describedby={!isUrlValid ? "link-url-error" : undefined}
283
+ />
284
+ </div>
285
+ {!isUrlValid && (
286
+ <p
287
+ id="link-url-error"
288
+ className="wn-link-dialog-error"
289
+ role="alert"
290
+ >
291
+ Please enter a valid URL (e.g. https://example.com)
292
+ </p>
293
+ )}
294
+ </div>
295
+
296
+ <div className="wn-link-dialog-field">
297
+ <label className="wn-link-dialog-label" htmlFor="link-title-input">
298
+ Title (Optional)
299
+ </label>
300
+ <input
301
+ id="link-title-input"
302
+ type="text"
303
+ className="wn-link-dialog-input"
304
+ value={title}
305
+ onChange={(e) => setTitle(e.target.value)}
306
+ placeholder="Hover text"
307
+ />
308
+ </div>
309
+ </div>
310
+
311
+ {/* Footer */}
312
+ <div className="wn-link-dialog-footer">
313
+ <button
314
+ className="wn-btn-secondary"
315
+ onClick={() => cancelLinkEdit()}
316
+ type="button"
317
+ >
318
+ Cancel
319
+ </button>
320
+ <button
321
+ className="wn-btn-primary"
322
+ onClick={handleSave}
323
+ disabled={!url || !isUrlValid}
324
+ type="button"
325
+ >
326
+ Save
327
+ </button>
328
+ </div>
329
+ </div>
330
+ </div>
331
+ );
332
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @fileoverview Editor component exports
3
+ * @module @writenex/astro/client/components/Editor
4
+ */
5
+
6
+ export { Editor, EditorLoading, EditorEmpty } from "./Editor";