seemore 1.1.5 → 1.2.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 +51 -29
- package/dist/cli/index.js +123 -8
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/app/features/edit.tsx +285 -0
- package/src/app/index.html +5 -2
- package/src/app/layout/DocsLayout.tsx +6 -1
- package/src/app/styles/globals.css +64 -0
- package/src/shared/types.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
* Types shared by the node pipeline and the browser app. This file ships as source, next to
|
|
5
5
|
* `src/app`, so both halves agree on the shape of the virtual modules.
|
|
6
6
|
*/
|
|
7
|
-
declare const FEATURES: readonly ["navigation.instant.prefetch", "navigation.instant.preview", "navigation.footer", "navigation.top", "navigation.path", "navigation.sections", "navigation.prune", "toc.follow", "toc.integrate", "content.code.copy", "content.action.edit", "content.image.zoom", "search.suggest", "search.highlight", "social.cards"];
|
|
7
|
+
declare const FEATURES: readonly ["navigation.instant.prefetch", "navigation.instant.preview", "navigation.footer", "navigation.top", "navigation.path", "navigation.sections", "navigation.prune", "toc.follow", "toc.integrate", "content.code.copy", "content.action.edit", "content.edit", "content.image.zoom", "search.suggest", "search.highlight", "social.cards"];
|
|
8
8
|
type Feature = (typeof FEATURES)[number];
|
|
9
9
|
/** What a user may write in `features`: a flag, or `!flag` to switch a default-on flag off. */
|
|
10
10
|
type FeatureFlag = Feature | `!${Feature}`;
|
package/package.json
CHANGED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { RouteEntry } from '../../shared/types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Editing a block of a page from the browser.
|
|
6
|
+
*
|
|
7
|
+
* What is edited is the block's **Markdown source**, not its rendered HTML. A rehype plugin
|
|
8
|
+
* stamped every editable block with its `start:end` offsets into the file, so a save replaces
|
|
9
|
+
* exactly those characters and every other byte of the file is left alone — no HTML-to-
|
|
10
|
+
* Markdown round trip to mangle a table, a fence or a link reference, and no whole-file
|
|
11
|
+
* reflow in the diff.
|
|
12
|
+
*
|
|
13
|
+
* Nothing here re-renders the page. The dev server writes the file, the watcher notices, and
|
|
14
|
+
* the page hot-reloads through the same path an edit made in an editor takes.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const ENDPOINT = '/__seemore/source';
|
|
18
|
+
|
|
19
|
+
interface Editing {
|
|
20
|
+
element: HTMLElement;
|
|
21
|
+
start: number;
|
|
22
|
+
end: number;
|
|
23
|
+
/**
|
|
24
|
+
* The slice exactly as the server sent it, kept out of the DOM.
|
|
25
|
+
*
|
|
26
|
+
* A textarea reports its value with `\n` whatever was put into it, so a CRLF file's block
|
|
27
|
+
* cannot be compared against the copy that came back through the editor. This is the copy
|
|
28
|
+
* the server checks the file against.
|
|
29
|
+
*/
|
|
30
|
+
original: string;
|
|
31
|
+
top: number;
|
|
32
|
+
left: number;
|
|
33
|
+
width: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function InlineEditor({ entry }: { entry: RouteEntry }) {
|
|
37
|
+
const layer = useRef<HTMLDivElement | null>(null);
|
|
38
|
+
const textarea = useRef<HTMLTextAreaElement | null>(null);
|
|
39
|
+
const [editing, setEditing] = useState<Editing | undefined>(undefined);
|
|
40
|
+
/** Read inside the dblclick listener, which is bound once and would capture stale state. */
|
|
41
|
+
const editingRef = useRef<Editing | undefined>(undefined);
|
|
42
|
+
const [error, setError] = useState<string | undefined>(undefined);
|
|
43
|
+
const [saving, setSaving] = useState(false);
|
|
44
|
+
/**
|
|
45
|
+
* Set when a save was refused, which in practice means the file moved under the offsets
|
|
46
|
+
* this editor is holding. It disarms the automatic save-on-blur: the text on screen is
|
|
47
|
+
* stale by definition, and leaving blur armed means a later click — or navigating away —
|
|
48
|
+
* silently commits it the moment the file happens to match `original` again.
|
|
49
|
+
*/
|
|
50
|
+
const [refused, setRefused] = useState(false);
|
|
51
|
+
|
|
52
|
+
const close = useCallback(() => {
|
|
53
|
+
setEditing((current) => {
|
|
54
|
+
current?.element.classList.remove('seemore-editing');
|
|
55
|
+
return undefined;
|
|
56
|
+
});
|
|
57
|
+
setError(undefined);
|
|
58
|
+
setRefused(false);
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
61
|
+
// The article is this layer's own parent, so there is no ref to thread down from the layout.
|
|
62
|
+
const article = () => layer.current?.parentElement ?? undefined;
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const host = article();
|
|
66
|
+
if (host === undefined) return;
|
|
67
|
+
|
|
68
|
+
const onDoubleClick = (event: MouseEvent) => {
|
|
69
|
+
const target = event.target as HTMLElement | null;
|
|
70
|
+
if (target === null) return;
|
|
71
|
+
// A link would have navigated on the first of the two clicks. Leave it alone; the rest
|
|
72
|
+
// of the paragraph around it still opens the editor.
|
|
73
|
+
if (target.closest('a') !== null) return;
|
|
74
|
+
|
|
75
|
+
const block = target.closest<HTMLElement>('[data-seemore-pos]');
|
|
76
|
+
if (block === null || !host.contains(block)) return;
|
|
77
|
+
|
|
78
|
+
// An editor is already open. Moving to another block is fine while nothing has been
|
|
79
|
+
// typed, but with unsaved text it would discard the edit without asking — so leave it
|
|
80
|
+
// where it is and let Save or Cancel decide.
|
|
81
|
+
const field = textarea.current;
|
|
82
|
+
if (field !== null && editingRef.current !== undefined) {
|
|
83
|
+
const original = editingRef.current.original;
|
|
84
|
+
if (field.value.replace(/\r\n/g, '\n') !== original.replace(/\r\n/g, '\n')) return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const [start, end] = (block.dataset['seemorePos'] ?? '').split(':').map(Number);
|
|
88
|
+
if (!Number.isInteger(start) || !Number.isInteger(end)) return;
|
|
89
|
+
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
void open(block, start as number, end as number);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const open = async (block: HTMLElement, start: number, end: number) => {
|
|
95
|
+
const query = new URLSearchParams({ file: entry.absPath, start: String(start), end: String(end) });
|
|
96
|
+
let original: string;
|
|
97
|
+
try {
|
|
98
|
+
const response = await fetch(`${ENDPOINT}?${query.toString()}`);
|
|
99
|
+
const body = (await response.json()) as { text?: string; error?: string };
|
|
100
|
+
if (!response.ok || typeof body.text !== 'string') {
|
|
101
|
+
setError(body.error ?? 'Could not read this block from the file.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
original = body.text;
|
|
105
|
+
} catch {
|
|
106
|
+
setError('Could not reach the dev server.');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Measured against the article, which is the positioning context of the layer.
|
|
111
|
+
const host = article();
|
|
112
|
+
if (host === undefined) return;
|
|
113
|
+
const bounds = host.getBoundingClientRect();
|
|
114
|
+
const rect = block.getBoundingClientRect();
|
|
115
|
+
|
|
116
|
+
block.classList.add('seemore-editing');
|
|
117
|
+
setError(undefined);
|
|
118
|
+
setRefused(false);
|
|
119
|
+
setEditing({
|
|
120
|
+
element: block,
|
|
121
|
+
start,
|
|
122
|
+
end,
|
|
123
|
+
original,
|
|
124
|
+
top: rect.top - bounds.top,
|
|
125
|
+
left: rect.left - bounds.left,
|
|
126
|
+
width: rect.width,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
host.addEventListener('dblclick', onDoubleClick);
|
|
131
|
+
return () => host.removeEventListener('dblclick', onDoubleClick);
|
|
132
|
+
}, [entry.absPath]);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
editingRef.current = editing;
|
|
136
|
+
}, [editing]);
|
|
137
|
+
|
|
138
|
+
// The block is hidden rather than removed while it is edited, so the page does not jump.
|
|
139
|
+
// If this component goes away mid-edit — a navigation, a hot reload — put it back.
|
|
140
|
+
useEffect(() => () => editing?.element.classList.remove('seemore-editing'), [editing]);
|
|
141
|
+
|
|
142
|
+
const save = useCallback(async () => {
|
|
143
|
+
const current = editing;
|
|
144
|
+
const field = textarea.current;
|
|
145
|
+
if (current === undefined || field === null || saving || refused) return;
|
|
146
|
+
|
|
147
|
+
// Both sides normalised: a textarea reports `\n` even for the `\r\n` a CRLF file gave it,
|
|
148
|
+
// so comparing raw would make every no-op edit on Windows look like a change and rewrite
|
|
149
|
+
// the file — same bytes, but a fresh mtime and a pointless reload.
|
|
150
|
+
if (field.value.replace(/\r\n/g, '\n') === current.original.replace(/\r\n/g, '\n')) {
|
|
151
|
+
close();
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setSaving(true);
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetch(ENDPOINT, {
|
|
158
|
+
method: 'PUT',
|
|
159
|
+
headers: { 'Content-Type': 'application/json' },
|
|
160
|
+
body: JSON.stringify({
|
|
161
|
+
file: entry.absPath,
|
|
162
|
+
start: current.start,
|
|
163
|
+
end: current.end,
|
|
164
|
+
expected: current.original,
|
|
165
|
+
text: field.value,
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const body = (await response.json().catch(() => ({}))) as { error?: string };
|
|
170
|
+
setError(body.error ?? 'The edit could not be saved.');
|
|
171
|
+
setRefused(true);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
// The watcher takes it from here: the file changed, so the page reloads its own module.
|
|
175
|
+
close();
|
|
176
|
+
} catch {
|
|
177
|
+
setError('Could not reach the dev server.');
|
|
178
|
+
setRefused(true);
|
|
179
|
+
} finally {
|
|
180
|
+
setSaving(false);
|
|
181
|
+
}
|
|
182
|
+
}, [editing, entry.absPath, close, saving, refused]);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="seemore-editor-layer" ref={layer}>
|
|
186
|
+
{editing === undefined ? undefined : (
|
|
187
|
+
<div
|
|
188
|
+
className="seemore-editor"
|
|
189
|
+
// `nearest` scrolls only when the box does not already fit, so opening an editor on
|
|
190
|
+
// a block that is comfortably in view does not move the page under the reader.
|
|
191
|
+
ref={(node) => node?.scrollIntoView({ block: 'nearest' })}
|
|
192
|
+
style={{ top: editing.top, left: editing.left, width: editing.width }}
|
|
193
|
+
>
|
|
194
|
+
<textarea
|
|
195
|
+
ref={(node) => {
|
|
196
|
+
textarea.current = node;
|
|
197
|
+
if (node === null) return;
|
|
198
|
+
node.focus();
|
|
199
|
+
node.setSelectionRange(node.value.length, node.value.length);
|
|
200
|
+
resize(node);
|
|
201
|
+
requestAnimationFrame(() => resize(node));
|
|
202
|
+
}}
|
|
203
|
+
className="seemore-editor-input"
|
|
204
|
+
// One row, so `height: auto` in `resize` collapses to the content rather than to
|
|
205
|
+
// the two-row default a textarea otherwise floors itself at.
|
|
206
|
+
rows={1}
|
|
207
|
+
defaultValue={editing.original}
|
|
208
|
+
spellCheck={false}
|
|
209
|
+
disabled={saving}
|
|
210
|
+
onInput={(event) => resize(event.currentTarget)}
|
|
211
|
+
onKeyDown={(event) => {
|
|
212
|
+
if (event.key === 'Escape') {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
close();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// `metaKey` on macOS, `ctrlKey` everywhere else — accept either rather than
|
|
218
|
+
// sniffing the platform.
|
|
219
|
+
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
void save();
|
|
222
|
+
}
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
<div className="seemore-editor-actions">
|
|
226
|
+
<span className="seemore-editor-status">
|
|
227
|
+
{saving ? 'Saving…' : refused ? 'Not saved — copy your text, then reload the page' : undefined}
|
|
228
|
+
</span>
|
|
229
|
+
{/*
|
|
230
|
+
`onMouseDown` is prevented on both buttons so focus never leaves the textarea.
|
|
231
|
+
Taking focus would fire its blur handler, which saves — so a click on Cancel
|
|
232
|
+
would commit the very edit it is meant to discard.
|
|
233
|
+
*/}
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
className="seemore-editor-button"
|
|
237
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
238
|
+
onClick={close}
|
|
239
|
+
>
|
|
240
|
+
Cancel
|
|
241
|
+
</button>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
className="seemore-editor-button seemore-editor-button-primary"
|
|
245
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
246
|
+
onClick={() => void save()}
|
|
247
|
+
disabled={saving || refused}
|
|
248
|
+
>
|
|
249
|
+
Save
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
)}
|
|
254
|
+
|
|
255
|
+
{error === undefined ? undefined : (
|
|
256
|
+
<p
|
|
257
|
+
className="seemore-editor-error"
|
|
258
|
+
role="alert"
|
|
259
|
+
title="Dismiss"
|
|
260
|
+
onClick={() => setError(undefined)}
|
|
261
|
+
>
|
|
262
|
+
{error}
|
|
263
|
+
</p>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Grow with the content: the Markdown behind a block is rarely the height of the block.
|
|
271
|
+
*
|
|
272
|
+
* Capped at part of the window, because a block whose source runs longer than the viewport
|
|
273
|
+
* would otherwise push Save and Cancel off the bottom of the screen. Past the cap the
|
|
274
|
+
* textarea scrolls inside itself and the action bar stays put.
|
|
275
|
+
*/
|
|
276
|
+
function resize(node: HTMLTextAreaElement): void {
|
|
277
|
+
const cap = Math.max(MIN_EDITOR_HEIGHT, Math.round(window.innerHeight * 0.55));
|
|
278
|
+
node.style.height = 'auto';
|
|
279
|
+
const wanted = node.scrollHeight;
|
|
280
|
+
node.style.height = `${Math.min(wanted, cap)}px`;
|
|
281
|
+
node.style.overflowY = wanted > cap ? 'auto' : 'hidden';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Floor for the cap, so a very short window still shows a usable amount of text. */
|
|
285
|
+
const MIN_EDITOR_HEIGHT = 160;
|
package/src/app/index.html
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<!-- Default icon so a zero-config folder does not 404 on /favicon.ico. A favicon from
|
|
7
|
-
the config is injected at the marker below, after this link, and the last one wins.
|
|
8
|
-
|
|
7
|
+
the config is injected at the marker below, after this link, and the last one wins.
|
|
8
|
+
This is seemore's own mark, inlined from assets/icon.svg at the repo root — the same
|
|
9
|
+
artwork the editor extension ships as its icon. Inlined rather than linked so the
|
|
10
|
+
built site needs no extra request and no file in the user's folder. -->
|
|
11
|
+
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 128 128'%3E%3Crect width='128' height='128' rx='28' fill='%236D5EF5'/%3E%3Crect x='30' y='26' width='52' height='68' rx='8' fill='%23ffffff' opacity='0.35' transform='rotate(-8 56 60)'/%3E%3Crect x='38' y='22' width='52' height='68' rx='8' fill='%23ffffff' opacity='0.6' transform='rotate(-3 64 56)'/%3E%3Crect x='46' y='24' width='52' height='72' rx='8' fill='%23ffffff'/%3E%3Cg stroke-linecap='round'%3E%3Cline x1='56' y1='42' x2='82' y2='42' stroke='%236D5EF5' stroke-width='6'/%3E%3Cline x1='56' y1='56' x2='88' y2='56' stroke='%23C6C8E6' stroke-width='4'/%3E%3Cline x1='56' y1='66' x2='80' y2='66' stroke='%23C6C8E6' stroke-width='4'/%3E%3Cline x1='56' y1='76' x2='88' y2='76' stroke='%236D5EF5' stroke-width='4'/%3E%3Cline x1='56' y1='86' x2='74' y2='86' stroke='%23C6C8E6' stroke-width='4'/%3E%3C/g%3E%3C/svg%3E" />
|
|
9
12
|
<!--seemore-head-->
|
|
10
13
|
</head>
|
|
11
14
|
<body>
|
|
@@ -13,6 +13,7 @@ import { usePrefetch } from '../features/prefetch.js';
|
|
|
13
13
|
import { PagePreview } from '../features/preview.js';
|
|
14
14
|
import { useSearchHighlight } from '../features/highlight.js';
|
|
15
15
|
import { useHashScroll } from '../features/anchors.js';
|
|
16
|
+
import { InlineEditor } from '../features/edit.js';
|
|
16
17
|
import { SeemoreProvider } from './Provider.js';
|
|
17
18
|
import { Header } from './Header.js';
|
|
18
19
|
import { Sidebar } from './Sidebar.js';
|
|
@@ -52,6 +53,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
52
53
|
useHashScroll();
|
|
53
54
|
|
|
54
55
|
const integrated = feature('toc.integrate');
|
|
56
|
+
// Dev only, and off unless asked for: the editor writes to the user's files, and a static
|
|
57
|
+
// build has no server to write through. `import.meta.env.DEV` also keeps it out of the bundle.
|
|
58
|
+
const editable = import.meta.env.DEV && feature('content.edit');
|
|
55
59
|
|
|
56
60
|
return (
|
|
57
61
|
<TocProvider toc={page.toc ?? []}>
|
|
@@ -62,8 +66,9 @@ export function DocPage({ entry }: { entry: RouteEntry }) {
|
|
|
62
66
|
|
|
63
67
|
<main className="seemore-main">
|
|
64
68
|
{feature('navigation.path') ? <Breadcrumb /> : undefined}
|
|
65
|
-
<article className=
|
|
69
|
+
<article className={editable ? 'seemore-article prose seemore-editable' : 'seemore-article prose'}>
|
|
66
70
|
<Content components={mdxComponents} />
|
|
71
|
+
{editable ? <InlineEditor key={entry.url} entry={entry} /> : undefined}
|
|
67
72
|
</article>
|
|
68
73
|
|
|
69
74
|
{config.editLink !== undefined && feature('content.action.edit') ? (
|
|
@@ -292,6 +292,70 @@
|
|
|
292
292
|
@apply my-6 rounded-lg border border-fd-border p-4 text-sm text-fd-muted-foreground;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
/* Inline editing (dev only, `content.edit`). The article is the positioning context for
|
|
296
|
+
the editor layer, which sits over the block being edited. */
|
|
297
|
+
.seemore-editable {
|
|
298
|
+
@apply relative;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.seemore-editable [data-seemore-pos]:hover {
|
|
302
|
+
@apply cursor-text rounded-sm bg-fd-muted/40;
|
|
303
|
+
box-shadow: 0 0 0 4px var(--color-fd-muted);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.seemore-editable .seemore-editing {
|
|
307
|
+
@apply invisible;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.seemore-editor-layer {
|
|
311
|
+
@apply pointer-events-none absolute inset-0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* `scroll-mt-20` clears the sticky header: without it, scrolling a tall editor into view
|
|
315
|
+
tucks its first lines underneath the header bar. */
|
|
316
|
+
.seemore-editor {
|
|
317
|
+
@apply pointer-events-auto absolute z-20 scroll-mt-20 rounded-lg border border-fd-primary/40 bg-fd-background shadow-lg;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.seemore-editor-input {
|
|
321
|
+
@apply block w-full resize-none bg-transparent p-3 font-mono text-sm leading-relaxed text-fd-foreground outline-none;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.seemore-editor-actions {
|
|
325
|
+
@apply flex items-center justify-end gap-2 border-t border-fd-border px-2.5 py-2;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
.seemore-editor-status {
|
|
329
|
+
@apply me-auto ps-0.5 text-xs text-fd-muted-foreground;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
.seemore-editor-button {
|
|
333
|
+
@apply rounded-md border border-fd-border px-3 py-1 text-xs font-medium text-fd-foreground transition-colors;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.seemore-editor-button:hover:not(:disabled) {
|
|
337
|
+
@apply bg-fd-accent text-fd-accent-foreground;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.seemore-editor-button:disabled {
|
|
341
|
+
@apply cursor-not-allowed opacity-50;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.seemore-editor-button-primary {
|
|
345
|
+
@apply border-transparent bg-fd-primary text-fd-primary-foreground;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.seemore-editor-button-primary:hover:not(:disabled) {
|
|
349
|
+
@apply bg-fd-primary/90 text-fd-primary-foreground;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/* Anchored to the viewport, not to the layer: the layer is `absolute inset-0` over the
|
|
353
|
+
article, so a `sticky` child of it renders at the article's top and lands on the
|
|
354
|
+
heading. A conflict is rare and worth reading, so it sits out of the content's way. */
|
|
355
|
+
.seemore-editor-error {
|
|
356
|
+
@apply pointer-events-auto fixed bottom-6 left-1/2 z-50 max-w-[min(32rem,calc(100vw-2rem))] -translate-x-1/2 cursor-pointer rounded-lg border border-fd-border bg-fd-background px-4 py-2.5 text-sm text-fd-foreground shadow-xl;
|
|
357
|
+
}
|
|
358
|
+
|
|
295
359
|
.seemore-pdf {
|
|
296
360
|
@apply my-6 block;
|
|
297
361
|
}
|