astro-dev-edit 0.11.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/LICENSE +21 -0
- package/README.md +125 -0
- package/package.json +52 -0
- package/src/client/admin-bar.ts +622 -0
- package/src/client/api.ts +370 -0
- package/src/client/classify-cache.ts +61 -0
- package/src/client/css-inspect.ts +345 -0
- package/src/client/editors/asset-picker.ts +155 -0
- package/src/client/editors/body-editor.ts +419 -0
- package/src/client/editors/collections-panel.ts +1532 -0
- package/src/client/editors/copy-panel.ts +73 -0
- package/src/client/editors/drawer.ts +95 -0
- package/src/client/editors/entry.ts +433 -0
- package/src/client/editors/expression.ts +77 -0
- package/src/client/editors/fields.ts +309 -0
- package/src/client/editors/image.ts +268 -0
- package/src/client/editors/markup-insert.ts +73 -0
- package/src/client/editors/markup.ts +125 -0
- package/src/client/editors/media-grid.ts +326 -0
- package/src/client/editors/media-modal.ts +588 -0
- package/src/client/editors/notice.ts +160 -0
- package/src/client/editors/peek.ts +135 -0
- package/src/client/editors/settings-panel.ts +457 -0
- package/src/client/editors/source-popup.ts +166 -0
- package/src/client/editors/text.ts +105 -0
- package/src/client/editors/unsplash-pane.ts +317 -0
- package/src/client/element-context.ts +308 -0
- package/src/client/features.ts +81 -0
- package/src/client/focus.ts +166 -0
- package/src/client/group.ts +186 -0
- package/src/client/highlight.ts +146 -0
- package/src/client/hover.ts +485 -0
- package/src/client/icons.ts +160 -0
- package/src/client/markdown.ts +319 -0
- package/src/client/overlay.ts +466 -0
- package/src/client/page-source.ts +143 -0
- package/src/client/router.ts +198 -0
- package/src/client/shadow.ts +111 -0
- package/src/client/source-map.ts +150 -0
- package/src/client/state.ts +153 -0
- package/src/client/styles.ts +3485 -0
- package/src/client/tree-model.ts +45 -0
- package/src/client/tree.ts +366 -0
- package/src/client/ui.ts +987 -0
- package/src/client/unsplash-search.ts +250 -0
- package/src/index.ts +299 -0
- package/src/patcher/astro.ts +792 -0
- package/src/patcher/content-config.ts +1035 -0
- package/src/patcher/dotenv.ts +121 -0
- package/src/patcher/expression-trace.ts +326 -0
- package/src/patcher/frontmatter.ts +249 -0
- package/src/patcher/registry.ts +11 -0
- package/src/patcher/types.ts +32 -0
- package/src/server/annotate.ts +173 -0
- package/src/server/assets.ts +167 -0
- package/src/server/collection-entries.ts +91 -0
- package/src/server/content-config.ts +210 -0
- package/src/server/editor.ts +15 -0
- package/src/server/entry-detect.ts +110 -0
- package/src/server/entry-resolve-routes.ts +218 -0
- package/src/server/entry-routes.ts +304 -0
- package/src/server/inspect-locate.ts +81 -0
- package/src/server/inspect-routes.ts +94 -0
- package/src/server/middleware.ts +480 -0
- package/src/server/options.ts +778 -0
- package/src/server/page-source-routes.ts +71 -0
- package/src/server/paths.ts +219 -0
- package/src/server/private-files.ts +116 -0
- package/src/server/route-manifest.ts +200 -0
- package/src/server/router.ts +94 -0
- package/src/server/schema-introspect.ts +233 -0
- package/src/server/schema-routes.ts +808 -0
- package/src/server/settings-routes.ts +246 -0
- package/src/server/settings.ts +382 -0
- package/src/server/text-writes.ts +105 -0
- package/src/server/unsplash-routes.ts +515 -0
- package/src/server/zod-adapt.ts +239 -0
- package/src/shared/asset-path.ts +132 -0
- package/src/shared/protocol.ts +935 -0
- package/src/shared/slug.ts +17 -0
- package/src/shared/unsplash.ts +51 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { canRichEdit, escapeHtml, htmlToMarkdown, markdownToHtml } from '../markdown.ts';
|
|
2
|
+
import { COLOR, FONT, PAPER, RADIUS, basename, hexToRgba, inputEl, isolateScroll, styled, toast } from '../ui.ts';
|
|
3
|
+
import { mountLight } from '../shadow.ts';
|
|
4
|
+
import { icon, type IconName } from '../icons.ts';
|
|
5
|
+
import { buildImageField } from './asset-picker.ts';
|
|
6
|
+
import { webPathToUrl } from '../../shared/asset-path.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* WYSIWYG markdown body editor for the entry drawer: a contenteditable
|
|
10
|
+
* surface with a formatting toolbar (bold/italic/strikethrough, heading
|
|
11
|
+
* level, lists, quote, code, link, image), backed by the subset converter in
|
|
12
|
+
* client/markdown.ts. Bodies outside that subset (tables, raw HTML/MDX, …)
|
|
13
|
+
* open in raw-markdown mode instead — the toolbar's MD/Rich toggle switches
|
|
14
|
+
* views, and the switch to Rich is refused when it would be lossy.
|
|
15
|
+
*
|
|
16
|
+
* Dirty tracking compares against the *normalized* round-trip of the initial
|
|
17
|
+
* body, so merely opening and closing the drawer never rewrites the file.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface BodyEditor {
|
|
21
|
+
root: HTMLElement;
|
|
22
|
+
/** Current markdown. */
|
|
23
|
+
value(): string;
|
|
24
|
+
/** Whether the user actually changed the body. */
|
|
25
|
+
dirty(): boolean;
|
|
26
|
+
/** Drop the light-DOM contenteditable this editor slots into the drawer.
|
|
27
|
+
* Call from the drawer's onClose — `root.remove()` cannot reach it, because
|
|
28
|
+
* it is parented to the shadow host rather than to `root`. */
|
|
29
|
+
destroy(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Content styling can't be inlined — the user creates these elements by typing
|
|
34
|
+
* — so the editor injects one class-scoped stylesheet.
|
|
35
|
+
*
|
|
36
|
+
* It lives in the **document**, not in the overlay's shadow stylesheet, because
|
|
37
|
+
* `.atx-rte-content` is the one piece of overlay chrome that stays in the light
|
|
38
|
+
* DOM: Safari's selection and `execCommand` APIs are inert against a node
|
|
39
|
+
* inside a shadow root, which would leave the toolbar doing nothing at all. The
|
|
40
|
+
* node is slotted back into the drawer instead (shadow.ts::mountLight), so it
|
|
41
|
+
* renders in place while remaining light-DOM for selection purposes — and is
|
|
42
|
+
* therefore styled from here, where the document can see it.
|
|
43
|
+
*/
|
|
44
|
+
const CONTENT_CSS = `
|
|
45
|
+
/* The writing surface itself. White, like the rendered page rather than a form
|
|
46
|
+
field, and color-scheme: light so native chrome (the scrollbar) matches it.
|
|
47
|
+
Hidden in source mode; [data-on] is the visual half of the MD/Rich toggle. */
|
|
48
|
+
.atx-rte-content {
|
|
49
|
+
display: none;
|
|
50
|
+
box-sizing: border-box;
|
|
51
|
+
width: 100%;
|
|
52
|
+
min-height: 40vh;
|
|
53
|
+
padding: 16px 20px;
|
|
54
|
+
border: none;
|
|
55
|
+
border-radius: ${RADIUS.lg};
|
|
56
|
+
background: ${PAPER.bg};
|
|
57
|
+
color: ${PAPER.fg};
|
|
58
|
+
font: 15px/1.65 ${FONT.ui};
|
|
59
|
+
color-scheme: light;
|
|
60
|
+
outline: none;
|
|
61
|
+
overflow-y: auto;
|
|
62
|
+
cursor: text;
|
|
63
|
+
}
|
|
64
|
+
.atx-rte-content[data-on] { display: block; }
|
|
65
|
+
.atx-rte-content h1, .atx-rte-content h2, .atx-rte-content h3,
|
|
66
|
+
.atx-rte-content h4, .atx-rte-content h5, .atx-rte-content h6 {
|
|
67
|
+
margin: 0.7em 0 0.35em; font-weight: 700; line-height: 1.25; color: inherit;
|
|
68
|
+
}
|
|
69
|
+
.atx-rte-content h1 { font-size: 1.55em; }
|
|
70
|
+
.atx-rte-content h2 { font-size: 1.35em; }
|
|
71
|
+
.atx-rte-content h3 { font-size: 1.18em; }
|
|
72
|
+
.atx-rte-content h4 { font-size: 1.05em; }
|
|
73
|
+
.atx-rte-content h5 { font-size: 0.95em; }
|
|
74
|
+
.atx-rte-content h6 { font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
75
|
+
.atx-rte-content p { margin: 0.5em 0; }
|
|
76
|
+
.atx-rte-content ul, .atx-rte-content ol { margin: 0.5em 0; padding-left: 1.5em; }
|
|
77
|
+
.atx-rte-content li { margin: 0.2em 0; }
|
|
78
|
+
.atx-rte-content blockquote {
|
|
79
|
+
margin: 0.6em 0; padding: 0.3em 0.9em; border-left: 3px solid ${COLOR.brand};
|
|
80
|
+
background: ${hexToRgba(COLOR.brand, 0.07)}; border-radius: 0 ${RADIUS.md} ${RADIUS.md} 0;
|
|
81
|
+
}
|
|
82
|
+
.atx-rte-content pre {
|
|
83
|
+
margin: 0.6em 0; padding: 8px 10px; background: ${PAPER.muted}; border: 1px solid ${PAPER.border};
|
|
84
|
+
border-radius: 6px; font: 12px/1.5 ${FONT.mono}; white-space: pre-wrap; overflow-x: auto;
|
|
85
|
+
}
|
|
86
|
+
.atx-rte-content code {
|
|
87
|
+
background: ${PAPER.muted}; border: 1px solid ${PAPER.border}; border-radius: 4px;
|
|
88
|
+
padding: 1px 4px; font-family: ${FONT.mono}; font-size: 0.9em;
|
|
89
|
+
}
|
|
90
|
+
.atx-rte-content pre code { background: transparent; border: none; padding: 0; }
|
|
91
|
+
.atx-rte-content a { color: ${PAPER.link}; }
|
|
92
|
+
.atx-rte-content img { max-width: 100%; border-radius: 4px; cursor: pointer; }
|
|
93
|
+
.atx-rte-content hr { border: none; border-top: 1px solid ${PAPER.border}; margin: 0.8em 0; }
|
|
94
|
+
`;
|
|
95
|
+
|
|
96
|
+
function ensureContentStyles(): void {
|
|
97
|
+
if (document.getElementById('atx-rte-style')) return;
|
|
98
|
+
const style = document.createElement('style');
|
|
99
|
+
style.id = 'atx-rte-style';
|
|
100
|
+
style.textContent = CONTENT_CSS;
|
|
101
|
+
document.head.append(style);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A toolbar button. `variant` is an extra class, not a style object: what each
|
|
105
|
+
* button does to its own label — bold, italic, struck through, monospaced — is
|
|
106
|
+
* a fixed choice from a known set, so it belongs in the stylesheet. */
|
|
107
|
+
/**
|
|
108
|
+
* One toolbar key. `label` is either a typographic mark the button *is* — B, I,
|
|
109
|
+
* the list bullets — or an `IconName`, for the three whose meaning no letter
|
|
110
|
+
* carries. Those three were emoji, which arrive in colour and at a different
|
|
111
|
+
* weight and baseline in every platform font: the one place in the overlay
|
|
112
|
+
* where the chrome was the operating system's rather than its own.
|
|
113
|
+
*/
|
|
114
|
+
function toolbarButton(
|
|
115
|
+
label: string | IconName,
|
|
116
|
+
title: string,
|
|
117
|
+
onRun: () => void,
|
|
118
|
+
variant = '',
|
|
119
|
+
glyph = false,
|
|
120
|
+
): HTMLButtonElement {
|
|
121
|
+
const b = styled('button', variant ? `atx-rte-btn ${variant}` : 'atx-rte-btn');
|
|
122
|
+
b.type = 'button';
|
|
123
|
+
if (glyph) b.append(icon(label as IconName, 16));
|
|
124
|
+
else b.textContent = label;
|
|
125
|
+
b.title = title;
|
|
126
|
+
// preventDefault keeps the contenteditable selection alive through the click.
|
|
127
|
+
b.addEventListener('mousedown', (e) => e.preventDefault());
|
|
128
|
+
b.addEventListener('click', onRun);
|
|
129
|
+
return b;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function divider(): HTMLElement {
|
|
133
|
+
return styled('span', 'atx-rte-divider');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Slot names must be unique per editor instance: a drawer hand-off can build
|
|
137
|
+
* the next editor before the previous one's node is gone. */
|
|
138
|
+
let rteSeq = 0;
|
|
139
|
+
|
|
140
|
+
export function buildBodyEditor(initial: string): BodyEditor {
|
|
141
|
+
ensureContentStyles();
|
|
142
|
+
|
|
143
|
+
const root = styled('div', 'atx-rte');
|
|
144
|
+
let mode: 'visual' | 'source' = canRichEdit(initial) ? 'visual' : 'source';
|
|
145
|
+
|
|
146
|
+
// --- the two surfaces ----------------------------------------------------
|
|
147
|
+
|
|
148
|
+
// The one control that can wear neither the shared [data-input] baseline nor
|
|
149
|
+
// a rule from the overlay's stylesheet: it lives in the light DOM, where
|
|
150
|
+
// selectors from the shadow root do not reach and ::slotted() loses to the
|
|
151
|
+
// document. Its whole box is in CONTENT_CSS instead, alongside the rules for
|
|
152
|
+
// the elements the user types into it.
|
|
153
|
+
const content = styled('div', 'atx-rte-content');
|
|
154
|
+
// The editing surface is the one node that does not move into the shadow
|
|
155
|
+
// root — see CONTENT_CSS above. It is parented to the host and composed back
|
|
156
|
+
// into the drawer through this slot, so layout is the drawer's job and
|
|
157
|
+
// selection keeps working in every engine.
|
|
158
|
+
const slotName = `atx-rte-${++rteSeq}`;
|
|
159
|
+
content.slot = slotName;
|
|
160
|
+
const contentSlot = document.createElement('slot');
|
|
161
|
+
contentSlot.name = slotName;
|
|
162
|
+
mountLight(content);
|
|
163
|
+
|
|
164
|
+
isolateScroll(content);
|
|
165
|
+
content.contentEditable = 'true';
|
|
166
|
+
content.addEventListener('focus', () => {
|
|
167
|
+
// Tag-based markup (<b>, <p>…), not styled spans — the serializer's format.
|
|
168
|
+
document.execCommand('styleWithCSS', false, 'false');
|
|
169
|
+
document.execCommand('defaultParagraphSeparator', false, 'p');
|
|
170
|
+
}, { once: true });
|
|
171
|
+
|
|
172
|
+
// Class kept from the old plain-textarea body input, so existing user CSS
|
|
173
|
+
// overrides keep working in source mode.
|
|
174
|
+
const srcInput = inputEl('textarea', 'atx-body-input');
|
|
175
|
+
srcInput.value = initial;
|
|
176
|
+
|
|
177
|
+
let visualBaseline: string | null = null;
|
|
178
|
+
if (mode === 'visual') {
|
|
179
|
+
content.innerHTML = markdownToHtml(initial);
|
|
180
|
+
visualBaseline = htmlToMarkdown(content);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- commands ------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
const exec = (cmd: string, val?: string): void => {
|
|
186
|
+
content.focus();
|
|
187
|
+
document.execCommand(cmd, false, val);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let savedRange: Range | null = null;
|
|
191
|
+
const saveSelection = (): void => {
|
|
192
|
+
const s = window.getSelection();
|
|
193
|
+
savedRange = s && s.rangeCount > 0 ? s.getRangeAt(0).cloneRange() : null;
|
|
194
|
+
};
|
|
195
|
+
const restoreSelection = (): void => {
|
|
196
|
+
if (!savedRange) return;
|
|
197
|
+
const s = window.getSelection();
|
|
198
|
+
s?.removeAllRanges();
|
|
199
|
+
s?.addRange(savedRange);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const insertInlineCode = (): void => {
|
|
203
|
+
const s = window.getSelection();
|
|
204
|
+
if (!s || s.isCollapsed || !content.contains(s.anchorNode)) return;
|
|
205
|
+
exec('insertHTML', `<code>${escapeHtml(s.toString())}</code>`);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const insertLink = (): void => {
|
|
209
|
+
saveSelection();
|
|
210
|
+
const url = window.prompt('Link URL', 'https://');
|
|
211
|
+
if (!url) return;
|
|
212
|
+
content.focus();
|
|
213
|
+
restoreSelection();
|
|
214
|
+
const s = window.getSelection();
|
|
215
|
+
if (!s || s.isCollapsed) exec('insertHTML', `<a href="${escapeHtml(url)}">${escapeHtml(url)}</a>`);
|
|
216
|
+
else exec('createLink', url);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// --- heading dropdown ----------------------------------------------------
|
|
220
|
+
|
|
221
|
+
const headWrap = styled('span', 'atx-rte-heading');
|
|
222
|
+
const headMenu = styled('div', 'atx-rte-heading-menu');
|
|
223
|
+
const hideMenu = (): void => {
|
|
224
|
+
headMenu.toggleAttribute('data-on', false);
|
|
225
|
+
};
|
|
226
|
+
const menuItem = (tag: string, chip: string, name: string, size: string): HTMLButtonElement => {
|
|
227
|
+
const item = styled('button', 'atx-rte-heading-item');
|
|
228
|
+
item.type = 'button';
|
|
229
|
+
const chipEl = styled('span', 'atx-rte-heading-chip');
|
|
230
|
+
chipEl.textContent = chip;
|
|
231
|
+
// The one size the stylesheet cannot know: each row previews its own level.
|
|
232
|
+
const nameEl = styled('span', 'atx-rte-heading-name', { fontSize: size });
|
|
233
|
+
nameEl.textContent = name;
|
|
234
|
+
item.append(chipEl, nameEl);
|
|
235
|
+
item.addEventListener('mousedown', (e) => e.preventDefault());
|
|
236
|
+
item.addEventListener('click', () => {
|
|
237
|
+
hideMenu();
|
|
238
|
+
exec('formatBlock', `<${tag}>`);
|
|
239
|
+
});
|
|
240
|
+
return item;
|
|
241
|
+
};
|
|
242
|
+
for (let i = 1; i <= 6; i++) {
|
|
243
|
+
headMenu.append(menuItem(`h${i}`, `H${i}`, `Heading ${i}`, `${17 - i}px`));
|
|
244
|
+
}
|
|
245
|
+
headMenu.append(menuItem('p', 'P', 'Paragraph', '12px'));
|
|
246
|
+
headWrap.append(
|
|
247
|
+
toolbarButton('Hx', 'Heading level', () => {
|
|
248
|
+
headMenu.toggleAttribute('data-on');
|
|
249
|
+
}),
|
|
250
|
+
headMenu,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// --- image insert/replace (reuses the asset-picker field: upload / browse /
|
|
254
|
+
// path). Opened by the toolbar button (insert at caret) or by clicking
|
|
255
|
+
// an image inside the content (replace that image).
|
|
256
|
+
|
|
257
|
+
const imagePanel = styled('div', 'atx-rte-image-panel');
|
|
258
|
+
let imageValue = '';
|
|
259
|
+
let replaceTarget: HTMLImageElement | null = null;
|
|
260
|
+
const imageFieldSlot = styled('div', 'atx-rte-image-slot');
|
|
261
|
+
|
|
262
|
+
// Alt text, auto-suggested from the picked file's name until edited by hand.
|
|
263
|
+
const altFromPath = (path: string): string =>
|
|
264
|
+
(basename(path).replace(/\.[a-z0-9]+$/i, '').replace(/[-_]+/g, ' ')).trim();
|
|
265
|
+
let altTouched = false;
|
|
266
|
+
const altLabel = styled('label', 'atx-rte-image-alt-label');
|
|
267
|
+
altLabel.textContent = 'Alt text';
|
|
268
|
+
const altInput = inputEl('input', 'atx-rte-image-alt');
|
|
269
|
+
altInput.type = 'text';
|
|
270
|
+
altInput.placeholder = 'Describe the image';
|
|
271
|
+
altInput.addEventListener('input', () => (altTouched = true));
|
|
272
|
+
|
|
273
|
+
const imageActions = styled('div', 'atx-rte-image-actions');
|
|
274
|
+
const smallBtn = (label: string, primary: boolean, onClick: () => void): HTMLButtonElement => {
|
|
275
|
+
const kind = primary ? 'default' : 'outline';
|
|
276
|
+
const b = styled('button', `atx-btn atx-btn-${kind} atx-btn-sm atx-rte-image-btn`);
|
|
277
|
+
b.type = 'button';
|
|
278
|
+
b.textContent = label;
|
|
279
|
+
b.addEventListener('click', onClick);
|
|
280
|
+
return b;
|
|
281
|
+
};
|
|
282
|
+
const confirmBtn = smallBtn('Insert', true, () => {
|
|
283
|
+
if (!imageValue) {
|
|
284
|
+
toast('Pick or upload an image first', 'err');
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
imagePanel.toggleAttribute('data-on', false);
|
|
288
|
+
const alt = altInput.value.trim();
|
|
289
|
+
if (replaceTarget && replaceTarget.isConnected) {
|
|
290
|
+
replaceTarget.src = imageValue;
|
|
291
|
+
replaceTarget.alt = alt;
|
|
292
|
+
} else {
|
|
293
|
+
content.focus();
|
|
294
|
+
restoreSelection();
|
|
295
|
+
exec('insertHTML', `<img src="${escapeHtml(imageValue)}" alt="${escapeHtml(alt)}">`);
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
imageActions.append(
|
|
299
|
+
smallBtn('Cancel', false, () => imagePanel.toggleAttribute('data-on', false)),
|
|
300
|
+
confirmBtn,
|
|
301
|
+
);
|
|
302
|
+
imagePanel.append(imageFieldSlot, altLabel, altInput, imageActions);
|
|
303
|
+
|
|
304
|
+
/** (Re)builds the field so the path/preview reflect this open, not the last. */
|
|
305
|
+
const openImagePanel = (prefill: string, target: HTMLImageElement | null): void => {
|
|
306
|
+
replaceTarget = target;
|
|
307
|
+
imageValue = prefill;
|
|
308
|
+
// Editing an existing image never rewrites its alt on its own — the source
|
|
309
|
+
// value is authoritative, even when blank. Filename auto-suggest applies
|
|
310
|
+
// only to a brand-new insert, and only until the field is edited by hand.
|
|
311
|
+
altInput.value = target?.getAttribute('alt') ?? '';
|
|
312
|
+
altTouched = target !== null || altInput.value !== '';
|
|
313
|
+
imageFieldSlot.textContent = '';
|
|
314
|
+
// Body images stay web-path shaped: a markdown `` in a rendered page
|
|
315
|
+
// resolves against the site, not the entry file (unlike an image() field).
|
|
316
|
+
imageFieldSlot.append(buildImageField({
|
|
317
|
+
initial: prefill,
|
|
318
|
+
onChange: (v, origin) => {
|
|
319
|
+
// The picker hands back a path describing a file, so it carries the
|
|
320
|
+
// filename's own characters. What goes into an `<img src>` — and from
|
|
321
|
+
// there into a markdown destination — is a URL, so a picked path is
|
|
322
|
+
// encoded on the way in. A value already stored or typed by hand is
|
|
323
|
+
// taken as written; re-encoding it would turn its `%20` into `%2520`.
|
|
324
|
+
imageValue = origin === 'picked' ? webPathToUrl(v) : v;
|
|
325
|
+
// Alt is suggested from the file's real name, not from the URL.
|
|
326
|
+
if (!altTouched) altInput.value = altFromPath(v);
|
|
327
|
+
},
|
|
328
|
+
}));
|
|
329
|
+
confirmBtn.textContent = target ? 'Replace' : 'Insert';
|
|
330
|
+
imagePanel.toggleAttribute('data-on', true);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Clicking an image in the content opens the panel targeting it.
|
|
334
|
+
content.addEventListener('click', (e) => {
|
|
335
|
+
const t = e.target;
|
|
336
|
+
if (t instanceof HTMLImageElement && content.contains(t)) {
|
|
337
|
+
saveSelection();
|
|
338
|
+
openImagePanel(t.getAttribute('src') ?? '', t);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
// --- toolbar -------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
const toolbar = styled('div', 'atx-rte-toolbar');
|
|
345
|
+
|
|
346
|
+
const modeBtn = toolbarButton('MD', 'Switch between rich text and markdown source', () => {
|
|
347
|
+
if (mode === 'visual') {
|
|
348
|
+
srcInput.value = htmlToMarkdown(content);
|
|
349
|
+
setMode('source');
|
|
350
|
+
} else {
|
|
351
|
+
if (!canRichEdit(srcInput.value)) {
|
|
352
|
+
toast('Body uses markdown the rich editor can’t preserve (tables, HTML, …)', 'err');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
content.innerHTML = markdownToHtml(srcInput.value);
|
|
356
|
+
setMode('visual');
|
|
357
|
+
}
|
|
358
|
+
}, 'atx-rte-mode');
|
|
359
|
+
|
|
360
|
+
const formatButtons = [
|
|
361
|
+
toolbarButton('B', 'Bold', () => exec('bold'), 'atx-rte-btn-bold'),
|
|
362
|
+
toolbarButton('I', 'Italic', () => exec('italic'), 'atx-rte-btn-italic'),
|
|
363
|
+
toolbarButton('S', 'Strikethrough', () => exec('strikeThrough'), 'atx-rte-btn-strike'),
|
|
364
|
+
divider(),
|
|
365
|
+
headWrap,
|
|
366
|
+
divider(),
|
|
367
|
+
toolbarButton('•–', 'Bulleted list', () => exec('insertUnorderedList')),
|
|
368
|
+
toolbarButton('1.', 'Numbered list', () => exec('insertOrderedList')),
|
|
369
|
+
divider(),
|
|
370
|
+
toolbarButton('quote', 'Quote', () => exec('formatBlock', '<blockquote>'), '', true),
|
|
371
|
+
toolbarButton('PRE', 'Code block', () => exec('formatBlock', '<pre>'), 'atx-rte-btn-pre'),
|
|
372
|
+
toolbarButton('`', 'Inline code', insertInlineCode, 'atx-rte-btn-code'),
|
|
373
|
+
divider(),
|
|
374
|
+
toolbarButton('link', 'Insert link', insertLink, '', true),
|
|
375
|
+
toolbarButton('image', 'Insert image', () => {
|
|
376
|
+
if (imagePanel.hasAttribute('data-on')) {
|
|
377
|
+
imagePanel.toggleAttribute('data-on', false);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
saveSelection();
|
|
381
|
+
openImagePanel('', null);
|
|
382
|
+
}, '', true),
|
|
383
|
+
];
|
|
384
|
+
toolbar.append(...formatButtons, modeBtn);
|
|
385
|
+
|
|
386
|
+
// Clicking into the text closes the heading menu.
|
|
387
|
+
content.addEventListener('mousedown', hideMenu);
|
|
388
|
+
|
|
389
|
+
const setMode = (next: 'visual' | 'source'): void => {
|
|
390
|
+
mode = next;
|
|
391
|
+
const visual = next === 'visual';
|
|
392
|
+
content.toggleAttribute('data-on', visual);
|
|
393
|
+
imagePanel.toggleAttribute('data-on', false);
|
|
394
|
+
hideMenu();
|
|
395
|
+
srcInput.toggleAttribute('data-on', !visual);
|
|
396
|
+
for (const el of formatButtons) el.toggleAttribute('data-hidden', !visual);
|
|
397
|
+
modeBtn.textContent = visual ? 'MD' : 'Rich';
|
|
398
|
+
};
|
|
399
|
+
setMode(mode);
|
|
400
|
+
|
|
401
|
+
// Toolbar and image panel share one sticky header, so the panel stays in
|
|
402
|
+
// view when it's opened for an image far down a long body.
|
|
403
|
+
const stickyHead = styled('div', 'atx-rte-head');
|
|
404
|
+
stickyHead.append(toolbar, imagePanel);
|
|
405
|
+
|
|
406
|
+
root.append(stickyHead, contentSlot, srcInput);
|
|
407
|
+
|
|
408
|
+
const value = (): string => (mode === 'visual' ? htmlToMarkdown(content) : srcInput.value);
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
root,
|
|
412
|
+
value,
|
|
413
|
+
dirty: () => {
|
|
414
|
+
const v = value();
|
|
415
|
+
return v !== initial && (visualBaseline === null || v !== visualBaseline);
|
|
416
|
+
},
|
|
417
|
+
destroy: () => content.remove(),
|
|
418
|
+
};
|
|
419
|
+
}
|