@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.
- package/README.md +539 -0
- package/dist/chunk-5PM6EQE5.js +151 -0
- package/dist/chunk-5PM6EQE5.js.map +1 -0
- package/dist/chunk-7XU5X6CW.js +1331 -0
- package/dist/chunk-7XU5X6CW.js.map +1 -0
- package/dist/chunk-AAOQHQPU.js +574 -0
- package/dist/chunk-AAOQHQPU.js.map +1 -0
- package/dist/chunk-CF2XXJFF.js +1410 -0
- package/dist/chunk-CF2XXJFF.js.map +1 -0
- package/dist/chunk-CRPZUUDU.js +52 -0
- package/dist/chunk-CRPZUUDU.js.map +1 -0
- package/dist/chunk-CYLDJ3HZ.js +310 -0
- package/dist/chunk-CYLDJ3HZ.js.map +1 -0
- package/dist/chunk-KIKIPIFA.js +1 -0
- package/dist/chunk-KIKIPIFA.js.map +1 -0
- package/dist/chunk-XNTQTTJU.js +145 -0
- package/dist/chunk-XNTQTTJU.js.map +1 -0
- package/dist/client/index.css +2 -0
- package/dist/client/index.css.map +1 -0
- package/dist/client/index.js +375 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/styles.css +584 -0
- package/dist/client/variables.css +304 -0
- package/dist/config/index.d.ts +54 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config-BmEdBDo_.d.ts +220 -0
- package/dist/content-BWR52vD-.d.ts +64 -0
- package/dist/discovery/index.d.ts +310 -0
- package/dist/discovery/index.js +38 -0
- package/dist/discovery/index.js.map +1 -0
- package/dist/errors-C0iYiDTv.d.ts +107 -0
- package/dist/filesystem/index.d.ts +1292 -0
- package/dist/filesystem/index.js +203 -0
- package/dist/filesystem/index.js.map +1 -0
- package/dist/image-FP7w5ZIs.d.ts +47 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-55LWCXHA.js +12 -0
- package/dist/loader-55LWCXHA.js.map +1 -0
- package/dist/loader-CrdnaAWR.d.ts +327 -0
- package/dist/server/index.d.ts +357 -0
- package/dist/server/index.js +37 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +94 -0
- package/src/client/App.tsx +900 -0
- package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
- package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
- package/src/client/components/ConfigPanel/index.ts +6 -0
- package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
- package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
- package/src/client/components/CreateContentModal/index.ts +7 -0
- package/src/client/components/Editor/Editor.css +885 -0
- package/src/client/components/Editor/Editor.tsx +484 -0
- package/src/client/components/Editor/ImageDialog.css +344 -0
- package/src/client/components/Editor/ImageDialog.tsx +367 -0
- package/src/client/components/Editor/LinkDialog.css +326 -0
- package/src/client/components/Editor/LinkDialog.tsx +332 -0
- package/src/client/components/Editor/index.ts +6 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
- package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
- package/src/client/components/FrontmatterForm/index.ts +7 -0
- package/src/client/components/Header/Header.css +300 -0
- package/src/client/components/Header/Header.tsx +300 -0
- package/src/client/components/Header/index.ts +7 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
- package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
- package/src/client/components/KeyboardShortcuts/index.ts +6 -0
- package/src/client/components/LazyEditor.tsx +75 -0
- package/src/client/components/LiveRegion/LiveRegion.css +19 -0
- package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
- package/src/client/components/LiveRegion/index.ts +7 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
- package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
- package/src/client/components/SearchReplace/index.ts +7 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
- package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
- package/src/client/components/SelectCollectionModal/index.ts +7 -0
- package/src/client/components/Sidebar/Sidebar.css +570 -0
- package/src/client/components/Sidebar/Sidebar.tsx +617 -0
- package/src/client/components/Sidebar/index.ts +7 -0
- package/src/client/components/SkipLink/SkipLink.css +51 -0
- package/src/client/components/SkipLink/SkipLink.tsx +67 -0
- package/src/client/components/SkipLink/index.ts +7 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
- package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
- package/src/client/components/UnsavedChangesModal/index.ts +1 -0
- package/src/client/components/VersionHistory/DiffViewer.css +430 -0
- package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
- package/src/client/components/VersionHistory/VersionActions.css +318 -0
- package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
- package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
- package/src/client/components/VersionHistory/index.ts +9 -0
- package/src/client/context/ApiContext.tsx +154 -0
- package/src/client/context/ThemeContext.tsx +172 -0
- package/src/client/hooks/useAnnounce.ts +201 -0
- package/src/client/hooks/useApi.ts +374 -0
- package/src/client/hooks/useArrowNavigation.ts +286 -0
- package/src/client/hooks/useAutosave.ts +241 -0
- package/src/client/hooks/useFocusTrap.ts +178 -0
- package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
- package/src/client/hooks/useSearch.ts +206 -0
- package/src/client/hooks/useVersionHistory.ts +451 -0
- package/src/client/index.tsx +70 -0
- package/src/client/styles.css +584 -0
- package/src/client/utils/focus.ts +57 -0
- package/src/client/utils/openInEditor.ts +130 -0
- package/src/client/variables.css +304 -0
- package/src/config/defaults.ts +109 -0
- package/src/config/index.ts +32 -0
- package/src/config/loader.ts +174 -0
- package/src/config/schema.ts +161 -0
- package/src/core/constants.ts +39 -0
- package/src/core/errors.ts +739 -0
- package/src/core/index.ts +11 -0
- package/src/discovery/collections.ts +216 -0
- package/src/discovery/index.ts +33 -0
- package/src/discovery/patterns.ts +702 -0
- package/src/discovery/schema.ts +453 -0
- package/src/filesystem/images.ts +798 -0
- package/src/filesystem/index.ts +107 -0
- package/src/filesystem/reader.ts +452 -0
- package/src/filesystem/version-config.ts +390 -0
- package/src/filesystem/versions.ts +1339 -0
- package/src/filesystem/watcher.ts +226 -0
- package/src/filesystem/writer.ts +540 -0
- package/src/index.ts +61 -0
- package/src/integration.ts +228 -0
- package/src/server/assets.ts +254 -0
- package/src/server/cache.ts +355 -0
- package/src/server/index.ts +33 -0
- package/src/server/middleware.ts +209 -0
- package/src/server/routes.ts +1428 -0
- package/src/types/api.ts +61 -0
- package/src/types/config.ts +134 -0
- package/src/types/content.ts +64 -0
- package/src/types/image.ts +48 -0
- package/src/types/index.ts +58 -0
- 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
|
+
}
|