svelte-rtf-editor 1.3.0 → 1.5.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 +107 -6
- package/dist/RtfViewer.svelte +41 -3
- package/dist/RtfViewer.svelte.d.ts +2 -0
- package/dist/components/ImageOverlay.svelte +302 -0
- package/dist/components/ImageOverlay.svelte.d.ts +27 -0
- package/dist/components/InkEditor.svelte +598 -54
- package/dist/components/InkEditor.svelte.d.ts +11 -0
- package/dist/images.d.ts +86 -0
- package/dist/images.js +286 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -1
- package/dist/rtf-parser.d.ts +2 -1
- package/dist/rtf-parser.js +254 -37
- package/dist/rtf-writer.d.ts +56 -4
- package/dist/rtf-writer.js +384 -35
- package/dist/utils.js +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ RTF viewer and rich-text editor components for **Svelte 5**.
|
|
|
13
13
|
- **`InkEditor`** — Full rich-text editor that reads and writes RTF, built on `contenteditable`
|
|
14
14
|
- Formatting toolbar: bold, italic, underline, strikethrough, font colour
|
|
15
15
|
- Block-level controls: headings (H1–H3), paragraph, blockquote, code block
|
|
16
|
+
- Images: upload, paste or drag-and-drop, with a description below each picture, drag-to-resize and left/centre/right placement — embedded in the RTF, not linked
|
|
16
17
|
- Import an RTF file from disk; export as RTF, HTML, or Markdown
|
|
17
18
|
- Optional auto-save to `localStorage`
|
|
18
19
|
- Word count and character count status bar
|
|
@@ -104,9 +105,11 @@ Pass an HTML string as `content`. If the content is already RTF, convert it firs
|
|
|
104
105
|
|
|
105
106
|
### `<RtfViewer>`
|
|
106
107
|
|
|
107
|
-
| Prop
|
|
108
|
-
|
|
109
|
-
| `content`
|
|
108
|
+
| Prop | Type | Default | Description |
|
|
109
|
+
|--------------|----------|-----------|------------------------------------------|
|
|
110
|
+
| `content` | `string` | — | Raw RTF string to parse and display |
|
|
111
|
+
| `fontFamily` | `string` | `'Arial'` | Font the document is shown in |
|
|
112
|
+
| `fontSize` | `number` | `12` | Body text size in points |
|
|
110
113
|
|
|
111
114
|
---
|
|
112
115
|
|
|
@@ -124,12 +127,17 @@ Pass an HTML string as `content`. If the content is already RTF, convert it firs
|
|
|
124
127
|
| `showStatusBar` | `boolean` | `true` | Show the word/character count bar |
|
|
125
128
|
| `minHeight` | `string` | `'40vh'` | CSS `min-height` of the editing area |
|
|
126
129
|
| `readonly` | `boolean` | `false` | Disable editing |
|
|
130
|
+
| `maxImageEdge` | `number` | `1600` | Longest edge (px) an inserted image is scaled down to; `0` keeps full size |
|
|
131
|
+
| `maxImageBytes` | `number` | `524288` | Encoded byte ceiling per image; oversized pictures are re-encoded until they fit. `0` disables |
|
|
132
|
+
| `maxImageDisplayWidth` | `number` | `624` | Width (px) a newly inserted image is displayed at, unless the column is narrower — 6.5in of page. `0` fills the column |
|
|
133
|
+
| `fontFamily` | `string` | `'Arial'` | Document font, on screen and in the RTF. See [Fonts](#fonts) |
|
|
134
|
+
| `fontSize` | `number` | `12` | Body text size in points, on screen and in the RTF |
|
|
127
135
|
|
|
128
136
|
#### Callback props
|
|
129
137
|
|
|
130
138
|
| Prop | Payload | Description |
|
|
131
139
|
|------------|----------------------------------------------|---------------------------------------------|
|
|
132
|
-
| `onchange` | `{ html, text, wordCount, charCount }`
|
|
140
|
+
| `onchange` | `{ html, text, wordCount, charCount, estimatedRtfBytes }` | Fired on every content change |
|
|
133
141
|
| `onsave` | `{ html }` | Fired on auto-save or `Ctrl+S` |
|
|
134
142
|
| `onimport` | `{ html }` | Fired after a successful RTF file import |
|
|
135
143
|
|
|
@@ -141,6 +149,7 @@ editor.getText() // → string — plain text (no tags)
|
|
|
141
149
|
editor.setHTML(html) // → void — replace content programmatically
|
|
142
150
|
editor.getMarkdown() // → string — Markdown conversion of content
|
|
143
151
|
editor.getRTF() // → string — RTF conversion of content
|
|
152
|
+
editor.getRtfSize() // → number — exact byte size of that RTF
|
|
144
153
|
editor.clear() // → void — clear the editor
|
|
145
154
|
editor.focus() // → void — focus the editor
|
|
146
155
|
editor.exportFile(format) // → void — download as 'html' | 'md' | 'rtf'
|
|
@@ -152,19 +161,46 @@ editor.importRtf() // → void — open the file picker to import .rtf
|
|
|
152
161
|
## Utility functions
|
|
153
162
|
|
|
154
163
|
```ts
|
|
155
|
-
import {
|
|
164
|
+
import {
|
|
165
|
+
rtfToHtml, htmlToRtf, estimateRtfBytes, readRtfFile, htmlToMarkdown, downloadFile,
|
|
166
|
+
DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE
|
|
167
|
+
} from 'svelte-rtf-editor';
|
|
156
168
|
```
|
|
157
169
|
|
|
158
170
|
| Function | Description |
|
|
159
171
|
|---------------------------------------|----------------------------------------------------|
|
|
160
172
|
| `rtfToHtml(rtf: string): string` | Parse an RTF string and return an HTML string |
|
|
161
|
-
| `htmlToRtf(
|
|
173
|
+
| `htmlToRtf(el: HTMLElement, options?: { fontFamily?, fontSize? }): string` | Convert a DOM element's content to RTF, in Arial 12 pt unless told otherwise |
|
|
174
|
+
| `estimateRtfBytes(el: HTMLElement): number` | Approximate RTF size of a DOM element without converting it |
|
|
162
175
|
| `readRtfFile(file: File): Promise<string>` | Read a `.rtf` File object and return HTML |
|
|
163
176
|
| `htmlToMarkdown(el: HTMLElement): string` | Convert a DOM element's content to Markdown |
|
|
164
177
|
| `downloadFile(name, content, mime): void` | Trigger a file download in the browser |
|
|
165
178
|
|
|
166
179
|
---
|
|
167
180
|
|
|
181
|
+
## Fonts
|
|
182
|
+
|
|
183
|
+
The editor and the viewer show a document in the same font and size the RTF carries, so what you see on screen is what the receiving system gets. Both default to **Arial 12 pt**.
|
|
184
|
+
|
|
185
|
+
```svelte
|
|
186
|
+
<InkEditor fontFamily="Calibri" fontSize={11} bind:this={editor} />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
htmlToRtf(el, { fontFamily: 'Calibri', fontSize: 11 });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
- `fontFamily` is used for the whole document: body text, headings, lists and tables. Code keeps Courier New.
|
|
194
|
+
- `fontSize` is the body size in points. Headings scale from it: at 12 pt, `h1` is 24 pt, `h2` 18 pt and `h3` 14 pt.
|
|
195
|
+
- If you call `htmlToRtf` yourself on the editor's content, pass the same options you gave the editor, or the two produce different RTF.
|
|
196
|
+
- `fontFamily` names one font. Given a CSS list such as `'Arial, sans-serif'`, only the first family is used.
|
|
197
|
+
- `fontSize` is rounded to the nearest half-point, which is what RTF measures in, and kept between 1 and 1638 pt.
|
|
198
|
+
- `rtfToHtml` recognizes headings relative to the document's own body size, so a document written at any size reads back with the same headings.
|
|
199
|
+
|
|
200
|
+
The font has to be installed on the machine that displays the document. The RTF names it, and a reader without it substitutes one of its own.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
168
204
|
## Theming
|
|
169
205
|
|
|
170
206
|
The components use CSS custom properties with sensible fallbacks, so they work out of the box but are easy to customise. Override these in your own CSS:
|
|
@@ -192,7 +228,72 @@ The components use CSS custom properties with sensible fallbacks, so they work o
|
|
|
192
228
|
| `Ctrl+B` | Bold |
|
|
193
229
|
| `Ctrl+I` | Italic |
|
|
194
230
|
| `Ctrl+U` | Underline |
|
|
231
|
+
| `Ctrl+K` | Insert link |
|
|
195
232
|
| `Ctrl+S` | Save (fires `onsave`) |
|
|
233
|
+
| `Backspace` / `Delete` | Remove the selected image |
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## Working with images
|
|
238
|
+
|
|
239
|
+
Three ways to add a picture:
|
|
240
|
+
|
|
241
|
+
- **Toolbar** — the image button opens a dialog where you can pick one or several files from disk, or paste an image address
|
|
242
|
+
- **Paste** — paste an image straight from the clipboard (a screenshot, for example)
|
|
243
|
+
- **Drag and drop** — drop image files anywhere in the editing area; they are inserted where you dropped them
|
|
244
|
+
|
|
245
|
+
Each picture is inserted as a `<figure>` with a `<figcaption>` under it. Click the caption line and type to describe the image — the description travels with the picture through every export. Insert as many images as a document needs.
|
|
246
|
+
|
|
247
|
+
Click an image to select it. A frame appears with corner handles you can drag to resize (the aspect ratio is kept and the width is capped at the editor's text width), buttons for 25% / 50% / 100% width, left / centre / right placement, a shortcut to its description, and a remove button.
|
|
248
|
+
|
|
249
|
+
### How images are stored
|
|
250
|
+
|
|
251
|
+
Images are held inline as base64 data URLs, so a document is self-contained — it auto-saves, exports and re-imports with the pictures intact, and needs no image server.
|
|
252
|
+
|
|
253
|
+
- **RTF export** writes each picture as a real `\pict` group (`\pngblip` / `\jpegblip`) sized with `\picwgoal` / `\pichgoal`, so it opens with the images in Word, TextEdit and other RTF readers. The description follows as an italic line, tagged with an ignorable `{\*\inkcap}` destination that this library uses to re-attach it to the figure on import; other readers simply skip the tag.
|
|
254
|
+
- **RTF import** decodes `\pict` PNG and JPEG data (including pictures wrapped in `{\*\shppict}`) back into images at their stored size. Picture formats a browser cannot display — metafiles and device-dependent bitmaps — are skipped.
|
|
255
|
+
- **Formats**: PNG and JPEG are embedded as-is. Anything else (GIF, WebP, SVG, …) is re-encoded to PNG when inserted, since RTF carries no other bitmap types. Animated images keep their first frame.
|
|
256
|
+
- **Images added by address** are fetched and inlined so they survive export. When the fetch is blocked (CORS or offline) the address is kept and the picture still displays, but it exports as an `[Image: …]` placeholder instead of picture data — the editor says so at insert time rather than letting it go unnoticed until the document is read downstream.
|
|
257
|
+
- **Size**: RTF stores picture data as hexadecimal — two characters per byte — so an embedded image costs twice its file size in the document. Resizing an image in the editor changes its display size only; two caps applied at insert time are what bound the payload:
|
|
258
|
+
- `maxImageEdge` (1600 px) scales the picture down so neither edge exceeds it.
|
|
259
|
+
- `maxImageBytes` (512 KB, so roughly 1 MB of document per image) is the ceiling that actually matters. A picture over it is re-encoded until it fits: a PNG is tried as PNG first so screenshots and diagrams stay sharp, falls back to JPEG only when it is too heavy *and* has no transparency to lose, then steps down through 80%, 64% and 50% of the target size. Whichever candidate fits first is kept.
|
|
260
|
+
|
|
261
|
+
Set either to `0` to disable it. Both caps are applied by re-encoding through a `<canvas>`, so they only take effect in a browser — in a runtime without one (SSR, jsdom/happy-dom tests) the original bytes are passed through untouched. Do not rely on them to bound a payload assembled server-side.
|
|
262
|
+
- **Page fit**: a newly inserted picture is displayed at `maxImageDisplayWidth` (624 px) rather than filling the editor. RTF records the display size in twips — 624 px × 15 = 9360 twips = 6.5 in, the text width of a Letter page with 1 in margins — so pictures arrive sized to the page they will be printed or filed on, not to the width of the browser window. Resize handles still go up to the full column width when a picture deserves it.
|
|
263
|
+
|
|
264
|
+
### Sending RTF over a transport
|
|
265
|
+
|
|
266
|
+
`htmlToRtf` output is written to survive being carried inside another format — an HL7 `OBX-5` field, a JSON string, a CSV cell:
|
|
267
|
+
|
|
268
|
+
- **One line.** The document contains no CR or LF anywhere — not in picture data, and not in text either: a newline inside text content is collapsed to the space a browser would have rendered (RTF readers ignore raw CR/LF, so `Hello\nworld` used to render as "Helloworld"). `<pre>` blocks keep their line structure as `\line`.
|
|
269
|
+
- **Uppercase hex.** Picture bytes are written as uppercase hex, so a PNG begins with the `89504E470D0A1A0A` signature that receivers commonly match on.
|
|
270
|
+
- **Format read from the bytes.** `\pngblip` vs `\jpegblip` is decided by sniffing the image's leading bytes, never by its declared MIME type — an operating system assigns `File.type` from the file extension, so a WebP saved as `.png` claims to be PNG. Bytes that are neither PNG nor JPEG are re-encoded on insert, or exported as a text placeholder rather than as a blip whose declared type contradicts its content.
|
|
271
|
+
- **Sized for the pipe.** `maxImageBytes` bounds each picture, and the payload is roughly twice that in hex. Lower it to fit a field-length limit.
|
|
272
|
+
|
|
273
|
+
Some receivers require the whole field on one unbroken line when it carries an image — that is why the writer emits no line breaks at all, rather than wrapping picture data the way many RTF writers do.
|
|
274
|
+
|
|
275
|
+
### Knowing how big a document is
|
|
276
|
+
|
|
277
|
+
The transport cares about the size of the finished RTF, and pictures make that unpredictable. Two ways to read it:
|
|
278
|
+
|
|
279
|
+
```svelte
|
|
280
|
+
<script>
|
|
281
|
+
let editor;
|
|
282
|
+
let size = 0;
|
|
283
|
+
</script>
|
|
284
|
+
|
|
285
|
+
<!-- an estimate on every change, cheap enough for a live indicator -->
|
|
286
|
+
<InkEditor bind:this={editor} onchange={({ estimatedRtfBytes }) => (size = estimatedRtfBytes)} />
|
|
287
|
+
|
|
288
|
+
<!-- the exact figure, when it is time to send -->
|
|
289
|
+
<button onclick={() => console.log(editor.getRtfSize())}>Check size</button>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`getRtfSize()` does the full conversion — call it when a decision depends on it, not on every keystroke. `estimatedRtfBytes` counts picture data exactly (two characters per byte, which is what dominates) and approximates the rest, leaving an error of a few hundred bytes at most. The output is ASCII, so bytes and characters are the same number.
|
|
293
|
+
|
|
294
|
+
The library reports the size and stops there; whether an oversized document may be sent is the host application's decision.
|
|
295
|
+
|
|
296
|
+
Escaping for the carrying format is the integration layer's job, not the editor's. For HL7 v2 that means escaping the whole string's delimiters — `\` → `\E\`, `|` → `\F\`, `^` → `\S\`, `&` → `\T\`, `~` → `\R\` — because ordinary typed text ("Smith & Jones") reaches the RTF unescaped.
|
|
196
297
|
|
|
197
298
|
---
|
|
198
299
|
|
package/dist/RtfViewer.svelte
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { rtfToHtml } from "./rtf-parser.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import {
|
|
4
|
+
cssFontFamily,
|
|
5
|
+
normalizeFontSize,
|
|
6
|
+
DEFAULT_FONT_FAMILY,
|
|
7
|
+
DEFAULT_FONT_SIZE
|
|
8
|
+
} from "./rtf-writer.js";
|
|
9
|
+
|
|
10
|
+
// fontFamily and fontSize (points) set how the document is shown, and
|
|
11
|
+
// default to the font the editor writes, so a document reads the same in
|
|
12
|
+
// the viewer as in the editor.
|
|
13
|
+
let {
|
|
14
|
+
content,
|
|
15
|
+
fontFamily = DEFAULT_FONT_FAMILY,
|
|
16
|
+
fontSize = DEFAULT_FONT_SIZE
|
|
17
|
+
}: { content: string; fontFamily?: string; fontSize?: number } = $props();
|
|
18
|
+
|
|
19
|
+
const screenFontFamily = $derived(cssFontFamily(fontFamily));
|
|
20
|
+
const screenFontSize = $derived(normalizeFontSize(fontSize));
|
|
5
21
|
|
|
6
22
|
const htmlContent = $derived(rtfToHtml(content));
|
|
7
23
|
|
|
@@ -24,7 +40,11 @@
|
|
|
24
40
|
Copy RTF
|
|
25
41
|
{/if}
|
|
26
42
|
</button>
|
|
27
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
class="rtf-viewer"
|
|
45
|
+
style:font-family={screenFontFamily}
|
|
46
|
+
style:font-size="{screenFontSize}pt"
|
|
47
|
+
>
|
|
28
48
|
{@html htmlContent}
|
|
29
49
|
</div>
|
|
30
50
|
</div>
|
|
@@ -92,4 +112,22 @@
|
|
|
92
112
|
.rtf-viewer :global(p:last-child) {
|
|
93
113
|
margin-bottom: 0;
|
|
94
114
|
}
|
|
115
|
+
|
|
116
|
+
/* Pictures decoded from \pict groups keep their stored width but never
|
|
117
|
+
overflow the viewer. */
|
|
118
|
+
.rtf-viewer :global(img) {
|
|
119
|
+
max-width: 100%;
|
|
120
|
+
height: auto;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.rtf-viewer :global(figure) {
|
|
124
|
+
margin: 1em 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.rtf-viewer :global(figcaption) {
|
|
128
|
+
margin-top: 0.4em;
|
|
129
|
+
font-size: 0.85em;
|
|
130
|
+
font-style: italic;
|
|
131
|
+
color: var(--text-muted, #8a7e72);
|
|
132
|
+
}
|
|
95
133
|
</style>
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* ImageOverlay — selection frame for an image inside the editor.
|
|
4
|
+
*
|
|
5
|
+
* Renders outside the contenteditable area (so it never ends up in the
|
|
6
|
+
* exported HTML) and is absolutely positioned over the target image by
|
|
7
|
+
* `bounds`, which must be a positioned ancestor of the editing surface.
|
|
8
|
+
*
|
|
9
|
+
* Provides drag-to-resize corner handles plus a small toolbar for
|
|
10
|
+
* placement (left / centre / right), width presets, caption editing and
|
|
11
|
+
* deletion.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
/** Image currently selected in the editor, or null when nothing is selected. */
|
|
16
|
+
target?: HTMLImageElement | null;
|
|
17
|
+
/** Positioned element the overlay coordinates are relative to. */
|
|
18
|
+
bounds?: HTMLElement | null;
|
|
19
|
+
/** Widest the image may become — the editor's content box width in px. */
|
|
20
|
+
maxWidth?: () => number;
|
|
21
|
+
onchange?: () => void;
|
|
22
|
+
onremove?: () => void;
|
|
23
|
+
oncaption?: (img: HTMLImageElement) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { target = null, bounds = null, maxWidth, onchange, onremove, oncaption }: Props = $props();
|
|
27
|
+
|
|
28
|
+
const MIN_WIDTH = 40;
|
|
29
|
+
const CORNERS = ['nw', 'ne', 'sw', 'se'] as const;
|
|
30
|
+
|
|
31
|
+
let frame = $state({ left: 0, top: 0, width: 0, height: 0 });
|
|
32
|
+
let visible = $state(false);
|
|
33
|
+
let dragging = $state(false);
|
|
34
|
+
let sizeLabel = $state('');
|
|
35
|
+
|
|
36
|
+
/** Recompute the frame position — call after anything that reflows content. */
|
|
37
|
+
export function reposition(): void {
|
|
38
|
+
if (!target || !bounds || !target.isConnected) {
|
|
39
|
+
visible = false;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const t = target.getBoundingClientRect();
|
|
43
|
+
const b = bounds.getBoundingClientRect();
|
|
44
|
+
frame = { left: t.left - b.left, top: t.top - b.top, width: t.width, height: t.height };
|
|
45
|
+
visible = t.width > 0 && t.height > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
$effect(() => {
|
|
49
|
+
// Re-read `target` so the effect re-runs whenever the selection changes.
|
|
50
|
+
void target;
|
|
51
|
+
reposition();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function limit(): number {
|
|
55
|
+
const max = maxWidth?.() ?? 0;
|
|
56
|
+
return max > MIN_WIDTH ? max : Number.POSITIVE_INFINITY;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function applyWidth(px: number): void {
|
|
60
|
+
if (!target) return;
|
|
61
|
+
const width = Math.round(Math.min(Math.max(px, MIN_WIDTH), limit()));
|
|
62
|
+
// The width attribute would fight the style rule — the style is the
|
|
63
|
+
// single source of truth that both the RTF writer and CSS read.
|
|
64
|
+
target.removeAttribute('width');
|
|
65
|
+
target.removeAttribute('height');
|
|
66
|
+
target.style.width = `${width}px`;
|
|
67
|
+
target.style.height = 'auto';
|
|
68
|
+
reposition();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function startResize(e: PointerEvent, corner: string): void {
|
|
72
|
+
if (!target) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
e.stopPropagation();
|
|
75
|
+
|
|
76
|
+
const rect = target.getBoundingClientRect();
|
|
77
|
+
const startX = e.clientX;
|
|
78
|
+
const startWidth = rect.width;
|
|
79
|
+
const ratio = rect.width > 0 ? rect.height / rect.width : 1;
|
|
80
|
+
const handle = e.currentTarget as HTMLElement;
|
|
81
|
+
|
|
82
|
+
dragging = true;
|
|
83
|
+
sizeLabel = `${Math.round(startWidth)} × ${Math.round(rect.height)}`;
|
|
84
|
+
handle.setPointerCapture?.(e.pointerId);
|
|
85
|
+
|
|
86
|
+
const onMove = (ev: PointerEvent) => {
|
|
87
|
+
// West-side handles grow the image as the pointer moves left.
|
|
88
|
+
const dx = corner.includes('w') ? startX - ev.clientX : ev.clientX - startX;
|
|
89
|
+
applyWidth(startWidth + dx);
|
|
90
|
+
const w = target ? target.getBoundingClientRect().width : 0;
|
|
91
|
+
sizeLabel = `${Math.round(w)} × ${Math.round(w * ratio)}`;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const onUp = (ev: PointerEvent) => {
|
|
95
|
+
handle.releasePointerCapture?.(ev.pointerId);
|
|
96
|
+
handle.removeEventListener('pointermove', onMove);
|
|
97
|
+
handle.removeEventListener('pointerup', onUp);
|
|
98
|
+
handle.removeEventListener('pointercancel', onUp);
|
|
99
|
+
dragging = false;
|
|
100
|
+
onchange?.();
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
handle.addEventListener('pointermove', onMove);
|
|
104
|
+
handle.addEventListener('pointerup', onUp);
|
|
105
|
+
handle.addEventListener('pointercancel', onUp);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function align(value: 'left' | 'center' | 'right'): void {
|
|
109
|
+
if (!target) return;
|
|
110
|
+
const block = target.closest('figure') ?? target.parentElement;
|
|
111
|
+
if (!block) return;
|
|
112
|
+
if (value === 'left') block.removeAttribute('style');
|
|
113
|
+
else (block as HTMLElement).style.textAlign = value;
|
|
114
|
+
reposition();
|
|
115
|
+
onchange?.();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function currentAlign(): string {
|
|
119
|
+
const block = target?.closest('figure') ?? target?.parentElement;
|
|
120
|
+
return (block as HTMLElement | null)?.style?.textAlign || 'left';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function scaleTo(percent: number): void {
|
|
124
|
+
const max = maxWidth?.() ?? 0;
|
|
125
|
+
if (!max) return;
|
|
126
|
+
applyWidth((max * percent) / 100);
|
|
127
|
+
onchange?.();
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<svelte:window onresize={reposition} onscroll={reposition} />
|
|
132
|
+
|
|
133
|
+
{#if visible && target}
|
|
134
|
+
<div
|
|
135
|
+
class="ink-img-frame"
|
|
136
|
+
class:dragging
|
|
137
|
+
style="left:{frame.left}px;top:{frame.top}px;width:{frame.width}px;height:{frame.height}px"
|
|
138
|
+
>
|
|
139
|
+
<div class="ink-img-tools">
|
|
140
|
+
<button
|
|
141
|
+
type="button"
|
|
142
|
+
title="Align left"
|
|
143
|
+
class:active={currentAlign() === 'left'}
|
|
144
|
+
onclick={() => align('left')}
|
|
145
|
+
>
|
|
146
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="18" y2="18"/></svg>
|
|
147
|
+
</button>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
title="Align center"
|
|
151
|
+
class:active={currentAlign() === 'center'}
|
|
152
|
+
onclick={() => align('center')}
|
|
153
|
+
>
|
|
154
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
|
155
|
+
</button>
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
title="Align right"
|
|
159
|
+
class:active={currentAlign() === 'right'}
|
|
160
|
+
onclick={() => align('right')}
|
|
161
|
+
>
|
|
162
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/></svg>
|
|
163
|
+
</button>
|
|
164
|
+
|
|
165
|
+
<span class="ink-img-sep"></span>
|
|
166
|
+
|
|
167
|
+
<button type="button" title="Quarter width" onclick={() => scaleTo(25)}>25%</button>
|
|
168
|
+
<button type="button" title="Half width" onclick={() => scaleTo(50)}>50%</button>
|
|
169
|
+
<button type="button" title="Full width" onclick={() => scaleTo(100)}>100%</button>
|
|
170
|
+
|
|
171
|
+
<span class="ink-img-sep"></span>
|
|
172
|
+
|
|
173
|
+
<button type="button" title="Edit description" onclick={() => target && oncaption?.(target)}>
|
|
174
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V5h16v2"/><line x1="9" y1="19" x2="15" y2="19"/><line x1="12" y1="5" x2="12" y2="19"/></svg>
|
|
175
|
+
</button>
|
|
176
|
+
<button type="button" class="danger" title="Remove image" onclick={() => onremove?.()}>
|
|
177
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{#each CORNERS as corner}
|
|
182
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
183
|
+
<span
|
|
184
|
+
class="ink-img-handle {corner}"
|
|
185
|
+
onpointerdown={(e) => startResize(e, corner)}
|
|
186
|
+
></span>
|
|
187
|
+
{/each}
|
|
188
|
+
|
|
189
|
+
{#if dragging}
|
|
190
|
+
<span class="ink-img-size">{sizeLabel}</span>
|
|
191
|
+
{/if}
|
|
192
|
+
</div>
|
|
193
|
+
{/if}
|
|
194
|
+
|
|
195
|
+
<style>
|
|
196
|
+
.ink-img-frame {
|
|
197
|
+
position: absolute;
|
|
198
|
+
box-sizing: border-box;
|
|
199
|
+
border: 1.5px solid var(--accent, #d4622b);
|
|
200
|
+
border-radius: 2px;
|
|
201
|
+
/* Clicks fall through to the image/text underneath; only the handles
|
|
202
|
+
and the toolbar are interactive. */
|
|
203
|
+
pointer-events: none;
|
|
204
|
+
z-index: 20;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.ink-img-handle {
|
|
208
|
+
position: absolute;
|
|
209
|
+
width: 11px;
|
|
210
|
+
height: 11px;
|
|
211
|
+
background: white;
|
|
212
|
+
border: 1.5px solid var(--accent, #d4622b);
|
|
213
|
+
border-radius: 2px;
|
|
214
|
+
pointer-events: auto;
|
|
215
|
+
touch-action: none;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.ink-img-handle.nw { top: -6px; left: -6px; cursor: nwse-resize; }
|
|
219
|
+
.ink-img-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
|
|
220
|
+
.ink-img-handle.sw { bottom: -6px; left: -6px; cursor: nesw-resize; }
|
|
221
|
+
.ink-img-handle.se { bottom: -6px; right: -6px; cursor: nwse-resize; }
|
|
222
|
+
|
|
223
|
+
.ink-img-tools {
|
|
224
|
+
position: absolute;
|
|
225
|
+
bottom: calc(100% + 8px);
|
|
226
|
+
left: 50%;
|
|
227
|
+
transform: translateX(-50%);
|
|
228
|
+
display: flex;
|
|
229
|
+
align-items: center;
|
|
230
|
+
gap: 2px;
|
|
231
|
+
padding: 4px;
|
|
232
|
+
background: white;
|
|
233
|
+
border: 1px solid var(--border, #e5e2dc);
|
|
234
|
+
border-radius: var(--radius-sm, 5px);
|
|
235
|
+
box-shadow: var(--shadow-md, 0 4px 12px rgba(44, 37, 32, 0.12));
|
|
236
|
+
pointer-events: auto;
|
|
237
|
+
white-space: nowrap;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.dragging .ink-img-tools {
|
|
241
|
+
opacity: 0;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.ink-img-tools button {
|
|
245
|
+
display: inline-flex;
|
|
246
|
+
align-items: center;
|
|
247
|
+
justify-content: center;
|
|
248
|
+
min-width: 26px;
|
|
249
|
+
height: 26px;
|
|
250
|
+
padding: 0 5px;
|
|
251
|
+
border: none;
|
|
252
|
+
background: none;
|
|
253
|
+
border-radius: 4px;
|
|
254
|
+
color: var(--text-muted, #8a7e72);
|
|
255
|
+
font-family: 'DM Sans', sans-serif;
|
|
256
|
+
font-size: 11px;
|
|
257
|
+
font-weight: 500;
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.ink-img-tools button:hover {
|
|
263
|
+
background: var(--surface, #f2f0ec);
|
|
264
|
+
color: var(--text, #2c2520);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.ink-img-tools button.active {
|
|
268
|
+
background: var(--accent-soft, #fdf0e9);
|
|
269
|
+
color: var(--accent, #d4622b);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.ink-img-tools button.danger:hover {
|
|
273
|
+
background: #fdecea;
|
|
274
|
+
color: #d32f2f;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.ink-img-tools svg {
|
|
278
|
+
width: 14px;
|
|
279
|
+
height: 14px;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
.ink-img-sep {
|
|
283
|
+
width: 1px;
|
|
284
|
+
height: 16px;
|
|
285
|
+
margin: 0 4px;
|
|
286
|
+
background: var(--border, #e5e2dc);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.ink-img-size {
|
|
290
|
+
position: absolute;
|
|
291
|
+
bottom: 6px;
|
|
292
|
+
right: 6px;
|
|
293
|
+
padding: 2px 7px;
|
|
294
|
+
border-radius: 3px;
|
|
295
|
+
background: rgba(26, 23, 20, 0.78);
|
|
296
|
+
color: white;
|
|
297
|
+
font-family: 'DM Sans', sans-serif;
|
|
298
|
+
font-size: 11px;
|
|
299
|
+
font-variant-numeric: tabular-nums;
|
|
300
|
+
pointer-events: none;
|
|
301
|
+
}
|
|
302
|
+
</style>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImageOverlay — selection frame for an image inside the editor.
|
|
3
|
+
*
|
|
4
|
+
* Renders outside the contenteditable area (so it never ends up in the
|
|
5
|
+
* exported HTML) and is absolutely positioned over the target image by
|
|
6
|
+
* `bounds`, which must be a positioned ancestor of the editing surface.
|
|
7
|
+
*
|
|
8
|
+
* Provides drag-to-resize corner handles plus a small toolbar for
|
|
9
|
+
* placement (left / centre / right), width presets, caption editing and
|
|
10
|
+
* deletion.
|
|
11
|
+
*/
|
|
12
|
+
interface Props {
|
|
13
|
+
/** Image currently selected in the editor, or null when nothing is selected. */
|
|
14
|
+
target?: HTMLImageElement | null;
|
|
15
|
+
/** Positioned element the overlay coordinates are relative to. */
|
|
16
|
+
bounds?: HTMLElement | null;
|
|
17
|
+
/** Widest the image may become — the editor's content box width in px. */
|
|
18
|
+
maxWidth?: () => number;
|
|
19
|
+
onchange?: () => void;
|
|
20
|
+
onremove?: () => void;
|
|
21
|
+
oncaption?: (img: HTMLImageElement) => void;
|
|
22
|
+
}
|
|
23
|
+
declare const ImageOverlay: import("svelte").Component<Props, {
|
|
24
|
+
reposition: () => void;
|
|
25
|
+
}, "">;
|
|
26
|
+
type ImageOverlay = ReturnType<typeof ImageOverlay>;
|
|
27
|
+
export default ImageOverlay;
|