vuewrite 0.0.29 → 1.0.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/API.md +116 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/markdown.js +35 -17
- package/dist/vuewrite.d.ts +21 -7
- package/dist/vuewrite.js +322 -67
- package/package.json +9 -5
package/API.md
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# API
|
|
2
|
+
|
|
3
|
+
## Exports
|
|
4
|
+
|
|
5
|
+
`vuewrite`
|
|
6
|
+
|
|
7
|
+
- `TextEditor` — editable component
|
|
8
|
+
- `TextViewer` — read-only renderer
|
|
9
|
+
- `uid()` — incremental string id generator for new blocks
|
|
10
|
+
- types: `Block`, `Style`, `Decorator`, `Renderer`, `TextParser`, `TextEditorRef`
|
|
11
|
+
|
|
12
|
+
`vuewrite/markdown`
|
|
13
|
+
|
|
14
|
+
- `markdownToBlocks`, `blocksToMarkdown`
|
|
15
|
+
- types: `Block`, `Style`
|
|
16
|
+
|
|
17
|
+
## Types
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
type Style = { start: number; end: number; style: string; meta?: any }
|
|
21
|
+
type Block = { id: string; text: string; type?: string; styles?: Style[]; editable?: boolean }
|
|
22
|
+
type Decorator = (style: Style) => (HTMLAttributes & { tag?: string }) | undefined
|
|
23
|
+
type Renderer = (block: Block) => (HTMLAttributes & { tag?: string }) | undefined
|
|
24
|
+
type TextParser = (text: string) => Style[]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `Style` — an inline range `[start, end)` with a style name and optional `meta` (e.g. a link's `href`).
|
|
28
|
+
- `Block` — one line/paragraph. `type` is your own label (`h1`, `li`, `code`, …). `editable: false` marks an atomic block (image, divider) the caret skips over.
|
|
29
|
+
- `Decorator` — maps an active style to the element it renders as: `tag` (defaults to `span`) plus any attributes, `class`, `style`.
|
|
30
|
+
- `Renderer` — maps a block to its container element (`tag` plus attributes).
|
|
31
|
+
- `TextParser` — derives inline styles from text at render time (syntax highlighting, mentions). Not stored in the model.
|
|
32
|
+
|
|
33
|
+
## `<TextEditor>`
|
|
34
|
+
|
|
35
|
+
### Props
|
|
36
|
+
|
|
37
|
+
| Prop | Type | Description |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `modelValue` | `Block[] \| string` | `v-model`. Array of blocks, or a string in `single` mode. |
|
|
40
|
+
| `single` | `boolean` | Treat content as a single block; emits a string plus `update:styles`. |
|
|
41
|
+
| `styles` | `Style[]` | Style ranges for `single`/string mode (`v-model:styles`). |
|
|
42
|
+
| `decorator` | `Decorator` | Renders inline styles. |
|
|
43
|
+
| `renderer` | `Renderer` | Renders block containers. |
|
|
44
|
+
| `parser` | `TextParser` | Inline styles derived at render, not stored. |
|
|
45
|
+
| `htmlParser` | `(el: Element) => string \| null \| void` | On paste, maps an HTML element to a block type. |
|
|
46
|
+
| `autofocus` | `boolean` | Focus on mount. |
|
|
47
|
+
| `autoselect` | `boolean` | Focus and select all on mount. |
|
|
48
|
+
| `preventMultiline` | `boolean` | Disables soft `\n` breaks: every Enter starts a new block, and pasted newlines split into blocks. |
|
|
49
|
+
|
|
50
|
+
### Emits
|
|
51
|
+
|
|
52
|
+
| Event | Payload |
|
|
53
|
+
|---|---|
|
|
54
|
+
| `update:modelValue` | `Block[]`, or `string` in `single` mode |
|
|
55
|
+
| `update:styles` | `Style[]` (`single` mode) |
|
|
56
|
+
| `keydown` | `KeyboardEvent`, fired before built-in handling — call `preventDefault()` to take over |
|
|
57
|
+
|
|
58
|
+
Built-in keys: Enter (new block), Shift+Enter (soft break), Ctrl/Cmd+Z and Ctrl/Cmd+Y (undo/redo). Copy, cut and paste are handled — clipboard HTML carries inline styles, with a plain-text fallback.
|
|
59
|
+
|
|
60
|
+
### Slots
|
|
61
|
+
|
|
62
|
+
| Slot | Rendered for | Scope |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `default` | untyped blocks | `{ content, props, block }` |
|
|
65
|
+
| `[type]` | blocks whose `type` matches the slot name (e.g. `#code`) | `{ content, props, block }` |
|
|
66
|
+
| `placeholder` | the empty editor | — |
|
|
67
|
+
|
|
68
|
+
Inside a slot, bind `props` to your root element and call `content()` to render the block's editable text.
|
|
69
|
+
|
|
70
|
+
### Ref — `TextEditorRef`
|
|
71
|
+
|
|
72
|
+
Grab it with a template ref. Reactive getters plus methods that act on the current selection:
|
|
73
|
+
|
|
74
|
+
| Member | Type | Description |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `selection` | `{ anchor, focus }` | Each end is `{ blockId, offset }`. |
|
|
77
|
+
| `isFocused` | `boolean` | |
|
|
78
|
+
| `isCollapsed` | `boolean` | Selection is a caret, not a range. |
|
|
79
|
+
| `currentBlock` | `Block \| null` | Block under the caret; `null` when the selection spans blocks. |
|
|
80
|
+
| `currentStyles` | `Map<string, Style>` | Styles active across the whole selection. |
|
|
81
|
+
| `getCurrentBlocks()` | `Iterable<Block>` | Blocks the selection touches. |
|
|
82
|
+
| `toggleStyle(name)` | `void` | |
|
|
83
|
+
| `applyStyle(name, meta?)` | `void` | |
|
|
84
|
+
| `removeStyle(name)` | `void` | |
|
|
85
|
+
| `insertText(text)` | `void` | Insert at the caret. |
|
|
86
|
+
| `insertBlock(block)` | `void` | `Partial<Block>` — split and insert a typed or atomic block. |
|
|
87
|
+
| `addNewLine()` | `void` | |
|
|
88
|
+
| `removeNewLine()` | `void` | Merge with the previous block. |
|
|
89
|
+
| `removeCurrentBlock()` | `void` | |
|
|
90
|
+
| `selectAll()` | `void` | |
|
|
91
|
+
| `pushHistory(type)` | `void` | Record an undo step after mutating blocks directly. |
|
|
92
|
+
| `getClientRects(selection)` | `DOMRectList` | Selection geometry, for positioning popovers. |
|
|
93
|
+
|
|
94
|
+
When you mutate blocks directly (for example, changing a block's `type`), call `pushHistory(...)` so the change becomes undoable.
|
|
95
|
+
|
|
96
|
+
## `<TextViewer>`
|
|
97
|
+
|
|
98
|
+
Read-only renderer for the same blocks (previews, read views). Takes `modelValue`, `decorator`, `renderer`, `parser`, `styles`, and:
|
|
99
|
+
|
|
100
|
+
| Prop | Type | Description |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `listParser` | `(block: Block) => string \| void` | Return a wrapper tag (`ul`/`ol`) to group consecutive blocks into a list. |
|
|
103
|
+
|
|
104
|
+
Slots: `default` and `[type]`, with the same scope as the editor (without editing).
|
|
105
|
+
|
|
106
|
+
## Markdown — `vuewrite/markdown`
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
markdownToBlocks(markdown: string, previousBlocks?: Block[], options?: { softBreaks?: boolean }): Block[]
|
|
110
|
+
blocksToMarkdown(blocks: Block[], options?: { softBreaks?: boolean }): string
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- `previousBlocks` — pass the result of a prior parse to reuse ids, so unchanged blocks keep stable keys across edits.
|
|
114
|
+
- `softBreaks` — when `true`, a single newline is a soft break inside a block and blank lines separate paragraphs; `blocksToMarkdown` mirrors this (blank line between blocks, consecutive list items stay tight). Both default to `false`, where blocks join with a single newline and empty blocks act as blank lines.
|
|
115
|
+
|
|
116
|
+
Supported syntax: `#`/`##`/`###` headings, `-`/`*` and `1.` lists, fenced ` ``` ` code, `---` divider, inline `**bold**` `*italic*` `__underline__` `` `code` `` `[text](url)`, `::: type … :::` custom blocks, and `<tag attrs>…</tag>` / `<tag/>` XML blocks.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 den59k
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# VueWrite
|
|
2
|
+
|
|
3
|
+
Another rich text editor, built on Vue 3 reactivity. It ships no styles and no block types — you decide how everything renders, and it handles the editing model, selection, history and clipboard. About 11 kB gzipped, Vue 3 the only dependency.
|
|
4
|
+
|
|
5
|
+
[Demo](https://vuewrite.easix.ru) · [API reference](./API.md)
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install vuewrite
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## How it works
|
|
14
|
+
|
|
15
|
+
A document is a flat array of blocks. A block is plain `text` plus a list of style ranges:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
{ id: "1", text: "Hello world", styles: [{ start: 0, end: 5, style: "bold" }] }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Nothing is styled out of the box. A `decorator` turns a style into a tag/class/attributes; a `renderer` turns a block into its element. Bold, headings, lists, links, code blocks, images — you map them however you want.
|
|
22
|
+
|
|
23
|
+
## Quickstart
|
|
24
|
+
|
|
25
|
+
```vue
|
|
26
|
+
<template>
|
|
27
|
+
<TextEditor ref="editor" v-model="text" :decorator="decorator" class="editor" @keydown="onKeyDown" />
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup lang="ts">
|
|
31
|
+
import { ref } from 'vue'
|
|
32
|
+
import { TextEditor } from 'vuewrite'
|
|
33
|
+
import type { TextEditorRef, Style } from 'vuewrite'
|
|
34
|
+
|
|
35
|
+
const editor = ref<TextEditorRef>()
|
|
36
|
+
const text = ref([{ id: '1', text: '' }])
|
|
37
|
+
|
|
38
|
+
const decorator = (style: Style) => ({ class: style.style })
|
|
39
|
+
|
|
40
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
41
|
+
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyB') {
|
|
42
|
+
e.preventDefault()
|
|
43
|
+
editor.value?.toggleStyle('bold')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<style>
|
|
49
|
+
.editor { white-space: pre-wrap; outline: none; }
|
|
50
|
+
.editor .bold { font-weight: 700; }
|
|
51
|
+
</style>
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Components
|
|
55
|
+
|
|
56
|
+
- `TextEditor` — the editable, contenteditable component (`v-model` of blocks, or a string in `single` mode).
|
|
57
|
+
- `TextViewer` — a read-only renderer for the same blocks, for previews and read paths.
|
|
58
|
+
|
|
59
|
+
## Markdown
|
|
60
|
+
|
|
61
|
+
`vuewrite/markdown` converts between Markdown and blocks:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import { markdownToBlocks, blocksToMarkdown } from 'vuewrite/markdown'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Full props, the editor ref, slots, types and the Markdown options are in the [API reference](./API.md).
|
package/dist/markdown.js
CHANGED
|
@@ -42,6 +42,11 @@ function parseBlocks(lines, softBreaks) {
|
|
|
42
42
|
i++;
|
|
43
43
|
continue;
|
|
44
44
|
}
|
|
45
|
+
if (/^-{3,}$/.test(line)) {
|
|
46
|
+
push({ text: "", type: "hr", editable: false });
|
|
47
|
+
i++;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
45
50
|
const headingMatch = line.match(/^(#{1,3}) (.+)$/);
|
|
46
51
|
if (headingMatch) {
|
|
47
52
|
push({ type: `h${headingMatch[1].length}`, ...withStyles(parseInline(headingMatch[2])) });
|
|
@@ -221,14 +226,18 @@ const MARKERS = {
|
|
|
221
226
|
code: { open: "`", close: "`" }
|
|
222
227
|
};
|
|
223
228
|
const STANDARD_FIELDS = /* @__PURE__ */ new Set(["id", "text", "type", "styles", "editable"]);
|
|
224
|
-
|
|
225
|
-
|
|
229
|
+
const isListType = (type) => type === "li" || type === "ol";
|
|
230
|
+
function blocksToMarkdown(blocks, options = {}) {
|
|
231
|
+
const parts = [];
|
|
226
232
|
let olCounter = 0;
|
|
227
233
|
for (const block of blocks) {
|
|
228
234
|
if (block.type === "code") {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
235
|
+
parts.push({ md: "```\n" + block.text + "\n```", type: "code" });
|
|
236
|
+
olCounter = 0;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (block.type === "hr") {
|
|
240
|
+
parts.push({ md: "---", type: "hr" });
|
|
232
241
|
olCounter = 0;
|
|
233
242
|
continue;
|
|
234
243
|
}
|
|
@@ -240,9 +249,9 @@ function blocksToMarkdown(blocks) {
|
|
|
240
249
|
}).join(" ");
|
|
241
250
|
const prefix = attrStr ? ` ${attrStr}` : "";
|
|
242
251
|
if (block.editable === false || !block.text) {
|
|
243
|
-
|
|
252
|
+
parts.push({ md: `<${block.type}${prefix}/>`, type: block.type });
|
|
244
253
|
} else {
|
|
245
|
-
|
|
254
|
+
parts.push({ md: `<${block.type}${prefix}>${renderInline(block.text, block.styles ?? [])}</${block.type}>`, type: block.type });
|
|
246
255
|
}
|
|
247
256
|
olCounter = 0;
|
|
248
257
|
continue;
|
|
@@ -250,37 +259,46 @@ function blocksToMarkdown(blocks) {
|
|
|
250
259
|
const inline = renderInline(block.text, block.styles ?? []);
|
|
251
260
|
switch (block.type) {
|
|
252
261
|
case "h1":
|
|
253
|
-
|
|
262
|
+
parts.push({ md: `# ${inline}`, type: "h1" });
|
|
254
263
|
olCounter = 0;
|
|
255
264
|
break;
|
|
256
265
|
case "h2":
|
|
257
|
-
|
|
266
|
+
parts.push({ md: `## ${inline}`, type: "h2" });
|
|
258
267
|
olCounter = 0;
|
|
259
268
|
break;
|
|
260
269
|
case "h3":
|
|
261
|
-
|
|
270
|
+
parts.push({ md: `### ${inline}`, type: "h3" });
|
|
262
271
|
olCounter = 0;
|
|
263
272
|
break;
|
|
264
273
|
case "li":
|
|
265
|
-
|
|
274
|
+
parts.push({ md: `- ${inline}`, type: "li" });
|
|
266
275
|
olCounter = 0;
|
|
267
276
|
break;
|
|
268
277
|
case "ol":
|
|
269
|
-
|
|
278
|
+
parts.push({ md: `${++olCounter}. ${inline}`, type: "ol" });
|
|
270
279
|
break;
|
|
271
280
|
default:
|
|
272
281
|
if (block.type) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
parts.push({ md: `:::${block.type}
|
|
283
|
+
${inline}
|
|
284
|
+
:::`, type: block.type });
|
|
276
285
|
} else {
|
|
277
|
-
|
|
286
|
+
parts.push({ md: inline, type: void 0 });
|
|
278
287
|
}
|
|
279
288
|
olCounter = 0;
|
|
280
289
|
break;
|
|
281
290
|
}
|
|
282
291
|
}
|
|
283
|
-
return
|
|
292
|
+
if (!options.softBreaks) return parts.map((p) => p.md).join("\n");
|
|
293
|
+
let result = "";
|
|
294
|
+
for (let i = 0; i < parts.length; i++) {
|
|
295
|
+
if (i > 0) {
|
|
296
|
+
const tight = isListType(parts[i - 1].type) && isListType(parts[i].type);
|
|
297
|
+
result += tight ? "\n" : "\n\n";
|
|
298
|
+
}
|
|
299
|
+
result += parts[i].md;
|
|
300
|
+
}
|
|
301
|
+
return result;
|
|
284
302
|
}
|
|
285
303
|
function escapeMarkdown(text) {
|
|
286
304
|
return text.replace(/[\\*_`\[\]]/g, "\\$&");
|
package/dist/vuewrite.d.ts
CHANGED
|
@@ -48,7 +48,7 @@ declare type HistoryAction = {
|
|
|
48
48
|
selection: TextEditorSelection;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
declare type Renderer = (block: Block) => HTMLAttributes & {
|
|
51
|
+
export declare type Renderer = (block: Block) => HTMLAttributes & {
|
|
52
52
|
tag?: string;
|
|
53
53
|
} | undefined;
|
|
54
54
|
|
|
@@ -144,13 +144,16 @@ onKeydown?: ((...args: any[]) => any) | undefined;
|
|
|
144
144
|
declare class TextEditorHistory {
|
|
145
145
|
actions: HistoryAction[];
|
|
146
146
|
currentCursor: number;
|
|
147
|
-
|
|
148
|
-
private
|
|
147
|
+
/** Block ids as of the last full snapshot — used to detect structural changes. */
|
|
148
|
+
private cachedBlockIds;
|
|
149
|
+
/** Independent deep copy of the document as of the previous push. */
|
|
150
|
+
private cachedBlocks;
|
|
149
151
|
private store;
|
|
150
152
|
constructor(store: TextEditorStore);
|
|
151
153
|
private cacheBlockIds;
|
|
152
154
|
private blockIdsChanged;
|
|
153
|
-
|
|
155
|
+
/** Keep the cached document copy in sync after an incremental, single-block edit. */
|
|
156
|
+
private syncCachedBlock;
|
|
154
157
|
push(type: HistoryAction["type"]): void;
|
|
155
158
|
private applyAction;
|
|
156
159
|
undo(): void;
|
|
@@ -190,6 +193,9 @@ declare class TextEditorStore {
|
|
|
190
193
|
_isCollapsed: ComputedRef<boolean>;
|
|
191
194
|
get isCollapsed(): boolean;
|
|
192
195
|
isFocused: Ref<boolean, boolean>;
|
|
196
|
+
/** True while an IME composition is in progress (CJK, dead keys, mobile suggestions). */
|
|
197
|
+
isComposing: boolean;
|
|
198
|
+
private compositionSelection;
|
|
193
199
|
_currentBlock: ComputedRef< {
|
|
194
200
|
id: string;
|
|
195
201
|
text: string;
|
|
@@ -223,6 +229,14 @@ declare class TextEditorStore {
|
|
|
223
229
|
removeCurrentBlock(): void;
|
|
224
230
|
removeNewLine(): void;
|
|
225
231
|
onInput(_e: Event): void;
|
|
232
|
+
/** Called on `compositionstart`: remember where composition begins so the
|
|
233
|
+
* committed text can be applied there once the browser is done. */
|
|
234
|
+
startComposition(): void;
|
|
235
|
+
/** Called on `compositionend` with the committed text. The browser already
|
|
236
|
+
* inserted it into the DOM during composition; we re-apply it through the
|
|
237
|
+
* model (from the remembered start position) so the model — and the re-render
|
|
238
|
+
* it triggers — become the source of truth again, preserving styles/offsets. */
|
|
239
|
+
endComposition(data: string): void;
|
|
226
240
|
addNewLineBefore(): void;
|
|
227
241
|
addNewLine(): void;
|
|
228
242
|
addNewLineAfter(): {
|
|
@@ -250,7 +264,9 @@ declare class TextEditorStore {
|
|
|
250
264
|
selectAll(): void;
|
|
251
265
|
}
|
|
252
266
|
|
|
253
|
-
export declare
|
|
267
|
+
export declare type TextParser = (text: string) => Style[];
|
|
268
|
+
|
|
269
|
+
export declare const TextViewer: DefineComponent< {
|
|
254
270
|
modelValue: Block[] | string;
|
|
255
271
|
decorator?: Decorator | undefined;
|
|
256
272
|
renderer?: Renderer | undefined;
|
|
@@ -268,8 +284,6 @@ styles?: Style[] | undefined;
|
|
|
268
284
|
listParser?: ((block: Block) => string | undefined | void) | undefined;
|
|
269
285
|
}> & Readonly<{}>, {}, {}, {}, {}, string, ComponentProvideOptions, true, {}, any>;
|
|
270
286
|
|
|
271
|
-
declare type TextParser = (text: string) => Style[];
|
|
272
|
-
|
|
273
287
|
export declare const uid: () => string;
|
|
274
288
|
|
|
275
289
|
export { }
|
package/dist/vuewrite.js
CHANGED
|
@@ -53,22 +53,50 @@ const isEqual = (a, b) => {
|
|
|
53
53
|
}
|
|
54
54
|
return true;
|
|
55
55
|
};
|
|
56
|
+
const deepClone = (value) => JSON.parse(JSON.stringify(value));
|
|
57
|
+
const cloneStyle = (style) => {
|
|
58
|
+
const out = { start: style.start, end: style.end, style: style.style };
|
|
59
|
+
if (style.meta !== void 0) out.meta = deepClone(style.meta);
|
|
60
|
+
return out;
|
|
61
|
+
};
|
|
62
|
+
const cloneBlock = (block) => {
|
|
63
|
+
const out = { id: block.id, text: block.text };
|
|
64
|
+
if (block.type !== void 0) out.type = block.type;
|
|
65
|
+
if (block.editable !== void 0) out.editable = block.editable;
|
|
66
|
+
if (block.styles) out.styles = block.styles.map(cloneStyle);
|
|
67
|
+
return out;
|
|
68
|
+
};
|
|
69
|
+
const cloneBlocks = (blocks) => blocks.map(cloneBlock);
|
|
70
|
+
const cloneSelection = (selection) => ({
|
|
71
|
+
anchor: { blockId: selection.anchor.blockId, offset: selection.anchor.offset },
|
|
72
|
+
focus: { blockId: selection.focus.blockId, offset: selection.focus.offset }
|
|
73
|
+
});
|
|
56
74
|
class TextEditorHistory {
|
|
57
75
|
constructor(store) {
|
|
58
76
|
__publicField(this, "actions", []);
|
|
59
77
|
__publicField(this, "currentCursor", 0);
|
|
60
|
-
|
|
61
|
-
__publicField(this, "
|
|
78
|
+
/** Block ids as of the last full snapshot — used to detect structural changes. */
|
|
79
|
+
__publicField(this, "cachedBlockIds", []);
|
|
80
|
+
/** Independent deep copy of the document as of the previous push. */
|
|
81
|
+
__publicField(this, "cachedBlocks", []);
|
|
62
82
|
__publicField(this, "store");
|
|
63
|
-
__publicField(this, "tickStarted", false);
|
|
64
83
|
this.store = store;
|
|
65
|
-
window.showHistory = () => console.log(this.actions);
|
|
66
84
|
}
|
|
67
85
|
cacheBlockIds() {
|
|
68
|
-
this.
|
|
86
|
+
this.cachedBlockIds = this.store.blocks.map((item) => item.id);
|
|
69
87
|
}
|
|
70
88
|
blockIdsChanged() {
|
|
71
|
-
|
|
89
|
+
const blocks = this.store.blocks;
|
|
90
|
+
if (blocks.length !== this.cachedBlockIds.length) return true;
|
|
91
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
92
|
+
if (blocks[i].id !== this.cachedBlockIds[i]) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/** Keep the cached document copy in sync after an incremental, single-block edit. */
|
|
97
|
+
syncCachedBlock(block) {
|
|
98
|
+
const idx = this.cachedBlocks.findIndex((item) => item.id === block.id);
|
|
99
|
+
if (idx >= 0) this.cachedBlocks[idx] = cloneBlock(block);
|
|
72
100
|
}
|
|
73
101
|
push(type) {
|
|
74
102
|
var _a;
|
|
@@ -76,29 +104,28 @@ class TextEditorHistory {
|
|
|
76
104
|
this.actions.length = this.currentCursor + 1;
|
|
77
105
|
}
|
|
78
106
|
const lastAction = this.actions.length > 0 ? this.actions[this.actions.length - 1] : null;
|
|
79
|
-
const selection =
|
|
107
|
+
const selection = cloneSelection(this.store.selection);
|
|
80
108
|
const fullUpdate = type === "setText" || type === "addNewLine" || this.blockIdsChanged();
|
|
81
|
-
const blocksJson = JSON.stringify(this.store.blocks);
|
|
82
109
|
if (!fullUpdate && this.store.currentBlock) {
|
|
83
|
-
const currentBlock =
|
|
84
|
-
|
|
110
|
+
const currentBlock = cloneBlock(this.store.currentBlock);
|
|
111
|
+
const canCoalesce = lastAction !== null && lastAction.type === type && ((_a = lastAction.blocks[0]) == null ? void 0 : _a.id) === currentBlock.id && (type === "insertText" || type === "deleteContentBackward" || type === "deleteContentForward");
|
|
112
|
+
if (canCoalesce) {
|
|
85
113
|
lastAction.blocks = [currentBlock];
|
|
86
114
|
lastAction.selection = selection;
|
|
87
115
|
} else {
|
|
88
|
-
this.actions.push({ type, blocks: [currentBlock], selection, fullUpdate });
|
|
116
|
+
this.actions.push({ type, blocks: [currentBlock], selection, fullUpdate: false });
|
|
89
117
|
}
|
|
118
|
+
this.syncCachedBlock(currentBlock);
|
|
90
119
|
} else {
|
|
91
|
-
this.actions.push({ type, blocks:
|
|
92
|
-
}
|
|
93
|
-
this.currentCursor = this.actions.length - 1;
|
|
94
|
-
if (fullUpdate) {
|
|
120
|
+
this.actions.push({ type, blocks: cloneBlocks(this.store.blocks), selection, fullUpdate: true });
|
|
95
121
|
if (lastAction && !lastAction.fullUpdate) {
|
|
96
122
|
lastAction.fullUpdate = true;
|
|
97
|
-
lastAction.blocks =
|
|
123
|
+
lastAction.blocks = this.cachedBlocks;
|
|
98
124
|
}
|
|
125
|
+
this.cachedBlocks = cloneBlocks(this.store.blocks);
|
|
99
126
|
this.cacheBlockIds();
|
|
100
127
|
}
|
|
101
|
-
this.
|
|
128
|
+
this.currentCursor = this.actions.length - 1;
|
|
102
129
|
}
|
|
103
130
|
applyAction(action) {
|
|
104
131
|
if (action.fullUpdate) {
|
|
@@ -149,6 +176,9 @@ class TextEditorStore {
|
|
|
149
176
|
return this.selection.anchor.blockId === this.selection.focus.blockId && this.selection.anchor.offset === this.selection.focus.offset;
|
|
150
177
|
}));
|
|
151
178
|
__publicField(this, "isFocused", ref(false));
|
|
179
|
+
/** True while an IME composition is in progress (CJK, dead keys, mobile suggestions). */
|
|
180
|
+
__publicField(this, "isComposing", false);
|
|
181
|
+
__publicField(this, "compositionSelection", null);
|
|
152
182
|
__publicField(this, "_currentBlock", computed(() => {
|
|
153
183
|
if (this.selection.anchor.blockId !== this.selection.focus.blockId) return null;
|
|
154
184
|
return this.blocks.find((item) => item.id === this.selection.anchor.blockId) ?? null;
|
|
@@ -261,6 +291,9 @@ class TextEditorStore {
|
|
|
261
291
|
onInput(_e) {
|
|
262
292
|
const ev = _e;
|
|
263
293
|
if (ev.defaultPrevented) return;
|
|
294
|
+
if (this.isComposing || ev.inputType === "insertCompositionText" || ev.inputType === "deleteCompositionText") {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
264
297
|
ev.preventDefault();
|
|
265
298
|
const collapsed = this.isCollapsed;
|
|
266
299
|
if (!collapsed) this.deleteSelected();
|
|
@@ -301,6 +334,31 @@ class TextEditorStore {
|
|
|
301
334
|
this.history.push("insertText");
|
|
302
335
|
}
|
|
303
336
|
}
|
|
337
|
+
/** Called on `compositionstart`: remember where composition begins so the
|
|
338
|
+
* committed text can be applied there once the browser is done. */
|
|
339
|
+
startComposition() {
|
|
340
|
+
this.isComposing = true;
|
|
341
|
+
this.compositionSelection = {
|
|
342
|
+
anchor: { ...this.selection.anchor },
|
|
343
|
+
focus: { ...this.selection.focus }
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/** Called on `compositionend` with the committed text. The browser already
|
|
347
|
+
* inserted it into the DOM during composition; we re-apply it through the
|
|
348
|
+
* model (from the remembered start position) so the model — and the re-render
|
|
349
|
+
* it triggers — become the source of truth again, preserving styles/offsets. */
|
|
350
|
+
endComposition(data) {
|
|
351
|
+
this.isComposing = false;
|
|
352
|
+
const start = this.compositionSelection;
|
|
353
|
+
this.compositionSelection = null;
|
|
354
|
+
if (!start) return;
|
|
355
|
+
Object.assign(this.selection.anchor, start.anchor);
|
|
356
|
+
Object.assign(this.selection.focus, start.focus);
|
|
357
|
+
const hadSelection = !this.isCollapsed;
|
|
358
|
+
if (hadSelection) this.deleteSelected();
|
|
359
|
+
if (data) this.insertText(data);
|
|
360
|
+
if (data || hadSelection) this.history.push("insertText");
|
|
361
|
+
}
|
|
304
362
|
addNewLineBefore() {
|
|
305
363
|
const index = this.blocks.findIndex((item) => item.id === this.selection.anchor.blockId);
|
|
306
364
|
const block = { id: uid(), text: "" };
|
|
@@ -543,6 +601,8 @@ const _sfc_main$2 = defineComponent({
|
|
|
543
601
|
if (cacheEl) {
|
|
544
602
|
cacheNodes[0] = cacheEl.childNodes[0];
|
|
545
603
|
cacheNodes[1] = cacheEl.childNodes[cacheEl.childNodes.length - 1];
|
|
604
|
+
} else {
|
|
605
|
+
cacheNodes.length = 0;
|
|
546
606
|
}
|
|
547
607
|
emit("postrender");
|
|
548
608
|
});
|
|
@@ -641,25 +701,219 @@ const _sfc_main$2 = defineComponent({
|
|
|
641
701
|
};
|
|
642
702
|
}
|
|
643
703
|
});
|
|
704
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
705
|
+
"DIV",
|
|
706
|
+
"P",
|
|
707
|
+
"H1",
|
|
708
|
+
"H2",
|
|
709
|
+
"H3",
|
|
710
|
+
"H4",
|
|
711
|
+
"H5",
|
|
712
|
+
"H6",
|
|
713
|
+
"LI",
|
|
714
|
+
"BLOCKQUOTE",
|
|
715
|
+
"PRE",
|
|
716
|
+
"SECTION",
|
|
717
|
+
"ARTICLE",
|
|
718
|
+
"HEADER",
|
|
719
|
+
"FOOTER"
|
|
720
|
+
]);
|
|
721
|
+
const INLINE_STYLE_TAGS = {
|
|
722
|
+
B: "bold",
|
|
723
|
+
STRONG: "bold",
|
|
724
|
+
I: "italic",
|
|
725
|
+
EM: "italic",
|
|
726
|
+
U: "underline",
|
|
727
|
+
CODE: "code"
|
|
728
|
+
};
|
|
729
|
+
const STYLE_TO_TAG = { bold: "b", italic: "i", underline: "u", code: "code" };
|
|
730
|
+
const isContainerChild = (el) => BLOCK_TAGS.has(el.tagName) || el.tagName === "UL" || el.tagName === "OL" || el.tagName === "HR";
|
|
731
|
+
const parseColor = (styleAttr) => {
|
|
732
|
+
if (!styleAttr) return void 0;
|
|
733
|
+
const match = styleAttr.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
|
|
734
|
+
return match ? match[1].trim() : void 0;
|
|
735
|
+
};
|
|
736
|
+
const recordInlineStyles = (styles, el, start, end) => {
|
|
737
|
+
if (end <= start) return;
|
|
738
|
+
const name = INLINE_STYLE_TAGS[el.tagName];
|
|
739
|
+
if (name) styles.push({ start, end, style: name });
|
|
740
|
+
if (el.tagName === "A") {
|
|
741
|
+
const href = el.getAttribute("href");
|
|
742
|
+
styles.push(href ? { start, end, style: "link", meta: { href } } : { start, end, style: "link" });
|
|
743
|
+
}
|
|
744
|
+
const color = parseColor(el.getAttribute("style"));
|
|
745
|
+
if (color) styles.push({ start, end, style: "color", meta: { color } });
|
|
746
|
+
};
|
|
747
|
+
const append = (acc, node, preserve) => {
|
|
748
|
+
for (const child of node.childNodes) {
|
|
749
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
750
|
+
const value = child.textContent ?? "";
|
|
751
|
+
acc.text += preserve ? value : value.replace(/\s+/g, " ");
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
|
755
|
+
const el = child;
|
|
756
|
+
if (el.tagName === "BR") {
|
|
757
|
+
acc.text += "\n";
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const start = acc.text.length;
|
|
761
|
+
append(acc, el, preserve);
|
|
762
|
+
recordInlineStyles(acc.styles, el, start, acc.text.length);
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
const trimWithStyles = (text, styles) => {
|
|
766
|
+
const leading = text.length - text.replace(/^\s+/, "").length;
|
|
767
|
+
const trimmed = text.trim();
|
|
768
|
+
if (leading === 0 && trimmed.length === text.length) return { text, styles };
|
|
769
|
+
const adjusted = [];
|
|
770
|
+
for (const s of styles) {
|
|
771
|
+
const start = Math.max(0, s.start - leading);
|
|
772
|
+
const end = Math.min(trimmed.length, s.end - leading);
|
|
773
|
+
if (end > start) adjusted.push({ ...s, start, end });
|
|
774
|
+
}
|
|
775
|
+
return { text: trimmed, styles: adjusted };
|
|
776
|
+
};
|
|
777
|
+
const makeBlock = (result, type) => {
|
|
778
|
+
const block = type !== void 0 ? { text: result.text, type } : { text: result.text };
|
|
779
|
+
if (result.styles.length) block.styles = result.styles;
|
|
780
|
+
return block;
|
|
781
|
+
};
|
|
782
|
+
const htmlToBlocks = (root, htmlParser) => {
|
|
783
|
+
const out = [];
|
|
784
|
+
const walk = (node) => {
|
|
785
|
+
let buffer = { text: "", styles: [] };
|
|
786
|
+
const flush = () => {
|
|
787
|
+
const trimmed = trimWithStyles(buffer.text, buffer.styles);
|
|
788
|
+
if (trimmed.text) out.push(makeBlock(trimmed));
|
|
789
|
+
buffer = { text: "", styles: [] };
|
|
790
|
+
};
|
|
791
|
+
for (const child of node.childNodes) {
|
|
792
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
793
|
+
buffer.text += (child.textContent ?? "").replace(/\s+/g, " ");
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
|
797
|
+
const el = child;
|
|
798
|
+
const tag = el.tagName;
|
|
799
|
+
if (tag === "BR") {
|
|
800
|
+
buffer.text += "\n";
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
if (tag === "HR") {
|
|
804
|
+
flush();
|
|
805
|
+
out.push({ text: "", type: (htmlParser == null ? void 0 : htmlParser(el)) ?? "hr", editable: false });
|
|
806
|
+
continue;
|
|
807
|
+
}
|
|
808
|
+
if (tag === "UL" || tag === "OL") {
|
|
809
|
+
flush();
|
|
810
|
+
const defaultType = tag === "OL" ? "ol" : "li";
|
|
811
|
+
for (const li of el.children) {
|
|
812
|
+
const acc = { text: "", styles: [] };
|
|
813
|
+
append(acc, li, false);
|
|
814
|
+
out.push(makeBlock(trimWithStyles(acc.text, acc.styles), (htmlParser == null ? void 0 : htmlParser(li)) ?? defaultType));
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (BLOCK_TAGS.has(tag)) {
|
|
819
|
+
flush();
|
|
820
|
+
if (Array.from(el.children).some(isContainerChild)) {
|
|
821
|
+
walk(el);
|
|
822
|
+
} else {
|
|
823
|
+
const preserve = tag === "PRE";
|
|
824
|
+
const acc = { text: "", styles: [] };
|
|
825
|
+
append(acc, el, preserve);
|
|
826
|
+
const result = preserve ? acc : trimWithStyles(acc.text, acc.styles);
|
|
827
|
+
out.push(makeBlock(result, (htmlParser == null ? void 0 : htmlParser(el)) ?? void 0));
|
|
828
|
+
}
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
const start = buffer.text.length;
|
|
832
|
+
append(buffer, el, false);
|
|
833
|
+
recordInlineStyles(buffer.styles, el, start, buffer.text.length);
|
|
834
|
+
}
|
|
835
|
+
flush();
|
|
836
|
+
};
|
|
837
|
+
walk(root);
|
|
838
|
+
return out;
|
|
839
|
+
};
|
|
840
|
+
const escapeText = (text) => text.replace(/[&<>]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : ">").replace(/\n/g, "<br>");
|
|
841
|
+
const escapeAttr = (value) => value.replace(/[&<>"]/g, (c) => c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """);
|
|
842
|
+
const tagFor = (style) => {
|
|
843
|
+
var _a, _b;
|
|
844
|
+
if (style.style === "link") {
|
|
845
|
+
const href = ((_a = style.meta) == null ? void 0 : _a.href) ?? "";
|
|
846
|
+
return { open: href ? `<a href="${escapeAttr(href)}">` : "<a>", close: "</a>" };
|
|
847
|
+
}
|
|
848
|
+
if (style.style === "color") {
|
|
849
|
+
const color = ((_b = style.meta) == null ? void 0 : _b.color) ?? "";
|
|
850
|
+
return color ? { open: `<span style="color: ${escapeAttr(color)}">`, close: "</span>" } : null;
|
|
851
|
+
}
|
|
852
|
+
const tag = STYLE_TO_TAG[style.style];
|
|
853
|
+
return tag ? { open: `<${tag}>`, close: `</${tag}>` } : null;
|
|
854
|
+
};
|
|
855
|
+
const stylesToHtml = (text, styles) => {
|
|
856
|
+
if (!styles || styles.length === 0) return escapeText(text);
|
|
857
|
+
const bounds = /* @__PURE__ */ new Set([0, text.length]);
|
|
858
|
+
for (const s of styles) {
|
|
859
|
+
bounds.add(s.start);
|
|
860
|
+
bounds.add(s.end);
|
|
861
|
+
}
|
|
862
|
+
const points = [...bounds].sort((a, b) => a - b);
|
|
863
|
+
let html = "";
|
|
864
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
865
|
+
const from = points[i];
|
|
866
|
+
const to = points[i + 1];
|
|
867
|
+
if (to <= from) continue;
|
|
868
|
+
let chunk = escapeText(text.slice(from, to));
|
|
869
|
+
for (const s of styles) {
|
|
870
|
+
if (s.start <= from && s.end >= to) {
|
|
871
|
+
const wrap = tagFor(s);
|
|
872
|
+
if (wrap) chunk = wrap.open + chunk + wrap.close;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
html += chunk;
|
|
876
|
+
}
|
|
877
|
+
return html;
|
|
878
|
+
};
|
|
879
|
+
const sliceStyles = (styles, from, to) => {
|
|
880
|
+
if (!styles) return [];
|
|
881
|
+
const out = [];
|
|
882
|
+
for (const s of styles) {
|
|
883
|
+
const start = Math.max(s.start, from);
|
|
884
|
+
const end = Math.min(s.end, to);
|
|
885
|
+
if (end > start) out.push({ ...s, start: start - from, end: end - from });
|
|
886
|
+
}
|
|
887
|
+
return out;
|
|
888
|
+
};
|
|
644
889
|
const createClipboardEvents = (store, props) => {
|
|
645
890
|
const getSelected = () => {
|
|
646
891
|
const [start, end, startIndex, endIndex] = store.startAndEnd;
|
|
647
892
|
if (startIndex === endIndex) {
|
|
648
|
-
const
|
|
893
|
+
const block = store.blocks[startIndex];
|
|
894
|
+
const text2 = block.text.slice(start.offset, end.offset);
|
|
895
|
+
const styles = sliceStyles(block.styles, start.offset, end.offset);
|
|
649
896
|
return [
|
|
650
897
|
new ClipboardItem({
|
|
898
|
+
"text/html": new Blob([stylesToHtml(text2, styles)], { type: "text/html" }),
|
|
651
899
|
"text/plain": new Blob([text2], { type: "text/plain" })
|
|
652
900
|
})
|
|
653
901
|
];
|
|
654
902
|
}
|
|
655
|
-
const startText = store.blocks[startIndex].text.slice(start.offset);
|
|
656
|
-
const endText = store.blocks[endIndex].text.slice(0, end.offset);
|
|
657
903
|
const arr = [
|
|
658
|
-
{
|
|
659
|
-
|
|
660
|
-
|
|
904
|
+
{
|
|
905
|
+
type: store.blocks[startIndex].type,
|
|
906
|
+
text: store.blocks[startIndex].text.slice(start.offset),
|
|
907
|
+
styles: sliceStyles(store.blocks[startIndex].styles, start.offset, store.blocks[startIndex].text.length)
|
|
908
|
+
},
|
|
909
|
+
...store.blocks.slice(startIndex + 1, endIndex).map((b) => ({ type: b.type, text: b.text, styles: b.styles ?? [] })),
|
|
910
|
+
{
|
|
911
|
+
type: store.blocks[endIndex].type,
|
|
912
|
+
text: store.blocks[endIndex].text.slice(0, end.offset),
|
|
913
|
+
styles: sliceStyles(store.blocks[endIndex].styles, 0, end.offset)
|
|
914
|
+
}
|
|
661
915
|
];
|
|
662
|
-
const html = arr.map((item) => `<div>${item.text}</div>`).join("\n");
|
|
916
|
+
const html = arr.map((item) => item.type === "hr" ? "<hr>" : `<div>${stylesToHtml(item.text, item.styles)}</div>`).join("\n");
|
|
663
917
|
const text = arr.map((item) => item.text).join("\n");
|
|
664
918
|
return [
|
|
665
919
|
new ClipboardItem({
|
|
@@ -681,49 +935,42 @@ const createClipboardEvents = (store, props) => {
|
|
|
681
935
|
store.deleteSelected();
|
|
682
936
|
store.history.push("setText");
|
|
683
937
|
};
|
|
684
|
-
const
|
|
685
|
-
if (props.preventMultiline) {
|
|
686
|
-
const blocks = text.split("\n");
|
|
687
|
-
store.insertText(blocks[0]);
|
|
688
|
-
for (let i = 1; i < blocks.length; i++) {
|
|
689
|
-
store.addNewLine();
|
|
690
|
-
store.insertText(blocks[i]);
|
|
691
|
-
}
|
|
692
|
-
} else {
|
|
693
|
-
if (type && store.currentBlock) {
|
|
694
|
-
store.currentBlock.type = type;
|
|
695
|
-
}
|
|
938
|
+
const insertMultilineText = (text) => {
|
|
939
|
+
if (!props.preventMultiline) {
|
|
696
940
|
store.insertText(text);
|
|
941
|
+
return;
|
|
697
942
|
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
let isTextNode = false;
|
|
702
|
-
for (let _node of node.childNodes) {
|
|
703
|
-
if (_node.nodeType === Node.TEXT_NODE && ((_a = _node.textContent) == null ? void 0 : _a.trim()) || _node.nodeType == Node.ELEMENT_NODE && _node.tagName === "SPAN") {
|
|
704
|
-
isTextNode = true;
|
|
705
|
-
break;
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
if (isTextNode) {
|
|
709
|
-
const text = node.textContent;
|
|
710
|
-
if (!text) return;
|
|
711
|
-
insertText(text, type ?? ((_b = props.htmlParser) == null ? void 0 : _b.call(props, node)) ?? void 0);
|
|
943
|
+
const lines = text.split("\n");
|
|
944
|
+
store.insertText(lines[0]);
|
|
945
|
+
for (let i = 1; i < lines.length; i++) {
|
|
712
946
|
store.addNewLine();
|
|
713
|
-
|
|
947
|
+
store.insertText(lines[i]);
|
|
714
948
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
parseHtml(li, ((_c = props.htmlParser) == null ? void 0 : _c.call(props, li)) ?? void 0);
|
|
721
|
-
}
|
|
722
|
-
} else {
|
|
723
|
-
insertText(child.textContent ?? "", type ?? ((_d = props.htmlParser) == null ? void 0 : _d.call(props, child)) ?? void 0);
|
|
949
|
+
};
|
|
950
|
+
const applyBlocks = (blocks) => {
|
|
951
|
+
blocks.forEach((block, i) => {
|
|
952
|
+
const atomic = block.editable === false;
|
|
953
|
+
if (i > 0 || atomic && store.currentBlock !== null && store.currentBlock.text !== "") {
|
|
724
954
|
store.addNewLine();
|
|
725
955
|
}
|
|
726
|
-
|
|
956
|
+
if (store.currentBlock) {
|
|
957
|
+
if (block.type !== void 0) store.currentBlock.type = block.type;
|
|
958
|
+
if (block.editable !== void 0) store.currentBlock.editable = block.editable;
|
|
959
|
+
}
|
|
960
|
+
if (block.text) {
|
|
961
|
+
const at = store.selection.focus.offset;
|
|
962
|
+
insertMultilineText(block.text);
|
|
963
|
+
const target = store.currentBlock;
|
|
964
|
+
if (target && block.styles && block.styles.length && !props.preventMultiline) {
|
|
965
|
+
if (!target.styles) target.styles = [];
|
|
966
|
+
for (const s of block.styles) {
|
|
967
|
+
target.styles.push({ ...s, start: s.start + at, end: s.end + at });
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
});
|
|
972
|
+
const last = blocks[blocks.length - 1];
|
|
973
|
+
if (last && last.editable === false) store.addNewLine();
|
|
727
974
|
};
|
|
728
975
|
const parser = new DOMParser();
|
|
729
976
|
const onPaste = (e) => {
|
|
@@ -734,12 +981,12 @@ const createClipboardEvents = (store, props) => {
|
|
|
734
981
|
if (html) {
|
|
735
982
|
store.deleteSelected();
|
|
736
983
|
const dom = parser.parseFromString(html, "text/html");
|
|
737
|
-
|
|
984
|
+
applyBlocks(htmlToBlocks(dom.body, props.htmlParser));
|
|
738
985
|
} else {
|
|
739
986
|
const text = (_b = e.clipboardData) == null ? void 0 : _b.getData("text");
|
|
740
987
|
if (!text) return;
|
|
741
988
|
store.deleteSelected();
|
|
742
|
-
|
|
989
|
+
insertMultilineText(text);
|
|
743
990
|
}
|
|
744
991
|
store.history.push("setText");
|
|
745
992
|
};
|
|
@@ -807,6 +1054,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|
|
807
1054
|
}
|
|
808
1055
|
}, { deep: true });
|
|
809
1056
|
const onKeyDown = (e) => {
|
|
1057
|
+
if (e.isComposing || store.isComposing) return;
|
|
810
1058
|
emit("keydown", e);
|
|
811
1059
|
if (e.defaultPrevented) return;
|
|
812
1060
|
if (e.code === "Enter") {
|
|
@@ -833,6 +1081,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|
|
833
1081
|
};
|
|
834
1082
|
let cachedSelection = {};
|
|
835
1083
|
const onSelectionChange = () => {
|
|
1084
|
+
if (store.isComposing) return;
|
|
836
1085
|
const sel = window.getSelection();
|
|
837
1086
|
const anchor = findParent(sel.anchorNode, (el) => el.hasAttribute("data-vw-block-id") && el.parentElement === textEditorRef.value);
|
|
838
1087
|
if (anchor) {
|
|
@@ -875,6 +1124,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|
|
875
1124
|
};
|
|
876
1125
|
const applySelection = () => {
|
|
877
1126
|
var _a;
|
|
1127
|
+
if (store.isComposing) return;
|
|
878
1128
|
if (!store.isFocused.value) {
|
|
879
1129
|
cachedSelection = JSON.parse(JSON.stringify(store.selection));
|
|
880
1130
|
return;
|
|
@@ -910,6 +1160,9 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|
|
910
1160
|
}
|
|
911
1161
|
store.history.push("setText");
|
|
912
1162
|
});
|
|
1163
|
+
const onCompositionEnd = (e) => {
|
|
1164
|
+
store.endComposition(e.data ?? "");
|
|
1165
|
+
};
|
|
913
1166
|
const { onCut, onCopy, onPaste } = createClipboardEvents(store, props);
|
|
914
1167
|
const getClientRects = (selection) => {
|
|
915
1168
|
const anchor = getNode(selection.anchor.blockId);
|
|
@@ -949,11 +1202,13 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|
|
949
1202
|
onBeforeinput: _cache[0] || (_cache[0] = //@ts-ignore
|
|
950
1203
|
(...args) => unref(store).onInput && unref(store).onInput(...args)),
|
|
951
1204
|
onKeydown: onKeyDown,
|
|
952
|
-
|
|
1205
|
+
onCompositionstart: _cache[1] || (_cache[1] = ($event) => unref(store).startComposition()),
|
|
1206
|
+
onCompositionend: onCompositionEnd,
|
|
1207
|
+
onCopy: _cache[2] || (_cache[2] = //@ts-ignore
|
|
953
1208
|
(...args) => unref(onCopy) && unref(onCopy)(...args)),
|
|
954
|
-
onPaste: _cache[
|
|
1209
|
+
onPaste: _cache[3] || (_cache[3] = //@ts-ignore
|
|
955
1210
|
(...args) => unref(onPaste) && unref(onPaste)(...args)),
|
|
956
|
-
onCut: _cache[
|
|
1211
|
+
onCut: _cache[4] || (_cache[4] = //@ts-ignore
|
|
957
1212
|
(...args) => unref(onCut) && unref(onCut)(...args))
|
|
958
1213
|
}, [
|
|
959
1214
|
(openBlock(true), createElementBlock(Fragment, null, renderList(unref(store).blocks, (block) => {
|
|
@@ -1031,6 +1286,6 @@ const _sfc_main = defineComponent({
|
|
|
1031
1286
|
});
|
|
1032
1287
|
export {
|
|
1033
1288
|
_sfc_main$1 as TextEditor,
|
|
1034
|
-
_sfc_main as
|
|
1289
|
+
_sfc_main as TextViewer,
|
|
1035
1290
|
uid
|
|
1036
1291
|
};
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "vuewrite",
|
|
3
3
|
"description": "Rich Text Editor based on Vue3 reactivity",
|
|
4
4
|
"private": false,
|
|
5
|
-
"version": "0.0
|
|
5
|
+
"version": "1.0.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"author": "den59k",
|
|
@@ -24,19 +24,23 @@
|
|
|
24
24
|
},
|
|
25
25
|
"scripts": {
|
|
26
26
|
"build": "vue-tsc && vite build",
|
|
27
|
-
"dev": "vite build --watch"
|
|
27
|
+
"dev": "vite build --watch",
|
|
28
|
+
"test": "vitest run"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"vue": "^3"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@vitejs/plugin-vue": "^5.0.4",
|
|
35
|
+
"@vue/test-utils": "^2.4.11",
|
|
36
|
+
"happy-dom": "^20.10.3",
|
|
34
37
|
"typescript": "^5.2.2",
|
|
35
38
|
"vite": "^5.2.0",
|
|
36
39
|
"vite-plugin-dts": "^3.9.1",
|
|
40
|
+
"vitest": "^4.1.7",
|
|
41
|
+
"vue-tsc": "^2.0.6",
|
|
37
42
|
"vuesix": "^1.0.12",
|
|
38
|
-
"vuewrite-markdown": "0.0.1"
|
|
39
|
-
"vue-tsc": "^2.0.6"
|
|
43
|
+
"vuewrite-markdown": "0.0.1"
|
|
40
44
|
},
|
|
41
|
-
"files": ["dist"]
|
|
45
|
+
"files": ["dist", "README.md", "API.md", "LICENSE"]
|
|
42
46
|
}
|