vuewrite 0.0.28 → 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 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
@@ -1,148 +1,144 @@
1
1
  let _counter = 0;
2
2
  const uid = () => (++_counter).toString();
3
- function blockContentKey(block) {
4
- const { id: _id, ...rest } = block;
5
- return JSON.stringify(rest);
6
- }
7
- function lcsIndices(oldKeys, newKeys) {
8
- const m = oldKeys.length;
9
- const n = newKeys.length;
10
- const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
11
- for (let i2 = 1; i2 <= m; i2++) {
12
- for (let j2 = 1; j2 <= n; j2++) {
13
- dp[i2][j2] = oldKeys[i2 - 1] === newKeys[j2 - 1] ? dp[i2 - 1][j2 - 1] + 1 : Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
14
- }
15
- }
16
- const pairs = [];
17
- let i = m;
18
- let j = n;
19
- while (i > 0 && j > 0) {
20
- if (oldKeys[i - 1] === newKeys[j - 1]) {
21
- pairs.unshift([i - 1, j - 1]);
22
- i--;
23
- j--;
24
- } else if (dp[i - 1][j] >= dp[i][j - 1]) {
25
- i--;
26
- } else {
27
- j--;
28
- }
29
- }
30
- return pairs;
3
+ function markdownToBlocks(markdown, previousBlocks = [], options = {}) {
4
+ const raw = parseBlocks(markdown.split("\n"), options.softBreaks ?? false);
5
+ return reconcileIds(raw, previousBlocks);
31
6
  }
32
- function markdownToBlocks(markdown, previousBlocks = []) {
33
- const lines = markdown.split("\n");
7
+ function parseBlocks(lines, softBreaks) {
34
8
  const blocks = [];
9
+ const push = (block) => blocks.push({ id: "", ...block });
35
10
  let i = 0;
11
+ let prevWasBlank = false;
36
12
  while (i < lines.length) {
37
13
  const line = lines[i];
38
- const customFenceMatch = line.match(/^:::\s*(\S+)/);
39
- if (customFenceMatch) {
40
- const type = customFenceMatch[1];
41
- const bodyLines = [];
14
+ const wasBlank = prevWasBlank;
15
+ prevWasBlank = line === "";
16
+ const fenceMatch = line.match(/^:::\s*(\S+)/);
17
+ if (fenceMatch) {
18
+ const body = [];
42
19
  i++;
43
- while (i < lines.length && lines[i].trim() !== ":::") {
44
- bodyLines.push(lines[i]);
45
- i++;
46
- }
47
- const { text: text2, styles: styles2 } = parseInline(bodyLines.join("\n"));
48
- const block2 = { id: uid(), text: text2, type };
49
- if (styles2.length > 0) block2.styles = styles2;
50
- blocks.push(block2);
20
+ while (i < lines.length && lines[i].trim() !== ":::") body.push(lines[i++]);
21
+ push({ type: fenceMatch[1], ...withStyles(parseInline(body.join("\n"))) });
51
22
  i++;
52
23
  continue;
53
24
  }
54
- const xmlPairedMatch = line.match(/^<(\w[\w-]*)(\s[^>]*)?>(.+)<\/\1>$/);
55
- if (xmlPairedMatch) {
56
- const attrs = parseAttributes(xmlPairedMatch[2] ?? "");
57
- const { text: text2, styles: styles2 } = parseInline(xmlPairedMatch[3]);
58
- const block2 = { id: uid(), text: text2, type: xmlPairedMatch[1], ...attrs };
59
- if (styles2.length > 0) block2.styles = styles2;
60
- blocks.push(block2);
25
+ const xmlPaired = line.match(/^<(\w[\w-]*)(\s[^>]*)?>(.+)<\/\1>$/);
26
+ if (xmlPaired) {
27
+ push({ type: xmlPaired[1], ...parseAttributes(xmlPaired[2] ?? ""), ...withStyles(parseInline(xmlPaired[3])) });
61
28
  i++;
62
29
  continue;
63
30
  }
64
- const xmlSelfMatch = line.match(/^<(\w[\w-]*)(\s[^>]*)?\s*\/?>$/);
65
- if (xmlSelfMatch) {
66
- const attrs = parseAttributes(xmlSelfMatch[2] ?? "");
67
- const block2 = { id: uid(), text: "", type: xmlSelfMatch[1], editable: false, ...attrs };
68
- blocks.push(block2);
31
+ const xmlSelf = line.match(/^<(\w[\w-]*)(\s[^>]*)?\s*\/?>$/);
32
+ if (xmlSelf) {
33
+ push({ text: "", type: xmlSelf[1], editable: false, ...parseAttributes(xmlSelf[2] ?? "") });
69
34
  i++;
70
35
  continue;
71
36
  }
72
37
  if (line.startsWith("```")) {
73
38
  const codeLines = [];
74
39
  i++;
75
- while (i < lines.length && !lines[i].startsWith("```")) {
76
- codeLines.push(lines[i]);
77
- i++;
78
- }
79
- blocks.push({ id: uid(), text: codeLines.join("\n"), type: "code", editable: false });
40
+ while (i < lines.length && !lines[i].startsWith("```")) codeLines.push(lines[i++]);
41
+ push({ text: codeLines.join("\n"), type: "code", editable: false });
42
+ i++;
43
+ continue;
44
+ }
45
+ if (/^-{3,}$/.test(line)) {
46
+ push({ text: "", type: "hr", editable: false });
80
47
  i++;
81
48
  continue;
82
49
  }
83
50
  const headingMatch = line.match(/^(#{1,3}) (.+)$/);
84
51
  if (headingMatch) {
85
- const level = headingMatch[1].length;
86
- const type = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
87
- const { text: text2, styles: styles2 } = parseInline(headingMatch[2]);
88
- const block2 = { id: uid(), text: text2, type };
89
- if (styles2.length > 0) block2.styles = styles2;
90
- blocks.push(block2);
52
+ push({ type: `h${headingMatch[1].length}`, ...withStyles(parseInline(headingMatch[2])) });
91
53
  i++;
92
54
  continue;
93
55
  }
94
56
  const ulMatch = line.match(/^[-*] (.+)$/);
95
57
  if (ulMatch) {
96
- const { text: text2, styles: styles2 } = parseInline(ulMatch[1]);
97
- const block2 = { id: uid(), text: text2, type: "li" };
98
- if (styles2.length > 0) block2.styles = styles2;
99
- blocks.push(block2);
58
+ push({ type: "li", ...withStyles(parseInline(ulMatch[1])) });
100
59
  i++;
101
60
  continue;
102
61
  }
103
62
  const olMatch = line.match(/^\d+\. (.+)$/);
104
63
  if (olMatch) {
105
- const { text: text2, styles: styles2 } = parseInline(olMatch[1]);
106
- const block2 = { id: uid(), text: text2, type: "ol" };
107
- if (styles2.length > 0) block2.styles = styles2;
108
- blocks.push(block2);
64
+ push({ type: "ol", ...withStyles(parseInline(olMatch[1])) });
109
65
  i++;
110
66
  continue;
111
67
  }
112
68
  if (line === "") {
113
- if (blocks.length > 0 && i < lines.length - 1) {
114
- blocks.push({ id: uid(), text: "" });
115
- }
69
+ if (!softBreaks && blocks.length > 0 && i < lines.length - 1) push({ text: "" });
116
70
  i++;
117
71
  continue;
118
72
  }
119
- const { text, styles } = parseInline(line);
120
- const block = { id: uid(), text };
121
- if (styles.length > 0) block.styles = styles;
122
- blocks.push(block);
73
+ const parsed = parseInline(line);
74
+ const last = blocks[blocks.length - 1];
75
+ if (softBreaks && !wasBlank && (last == null ? void 0 : last.type) === void 0 && (last == null ? void 0 : last.text)) {
76
+ appendToLastParagraph(last, parsed);
77
+ } else {
78
+ push(withStyles(parsed));
79
+ }
123
80
  i++;
124
81
  }
125
- if (blocks.length === 0) {
126
- blocks.push({ id: uid(), text: "" });
82
+ if (blocks.length === 0) push({ text: "" });
83
+ return blocks;
84
+ }
85
+ function withStyles({ text, styles }) {
86
+ return styles.length > 0 ? { text, styles } : { text };
87
+ }
88
+ function appendToLastParagraph(block, { text, styles }) {
89
+ const offset = block.text.length + 1;
90
+ block.text += "\n" + text;
91
+ if (styles.length > 0) {
92
+ if (!block.styles) block.styles = [];
93
+ for (const s of styles) block.styles.push({ ...s, start: s.start + offset, end: s.end + offset });
127
94
  }
128
- if (previousBlocks.length === 0) return blocks;
129
- const oldKeys = previousBlocks.map(blockContentKey);
130
- const newKeys = blocks.map(blockContentKey);
95
+ }
96
+ function reconcileIds(blocks, previousBlocks) {
97
+ if (previousBlocks.length === 0) return blocks.map((b) => ({ ...b, id: uid() }));
98
+ const idMap = buildIdMap(blocks, previousBlocks);
99
+ return blocks.map((b, j) => ({ ...b, id: idMap.get(j) ?? uid() }));
100
+ }
101
+ function buildIdMap(blocks, previousBlocks) {
102
+ const oldKeys = previousBlocks.map(contentKey);
103
+ const newKeys = blocks.map(contentKey);
131
104
  const pairs = lcsIndices(oldKeys, newKeys);
132
- const oldIdByNewIndex = new Map(pairs.map(([i2, j]) => [j, previousBlocks[i2].id]));
133
- return blocks.map((block, j) => {
134
- const oldId = oldIdByNewIndex.get(j);
135
- return oldId !== void 0 ? { ...block, id: oldId } : block;
136
- });
105
+ const map = new Map(pairs.map(([i, j]) => [j, previousBlocks[i].id]));
106
+ const lastJ = blocks.length - 1;
107
+ const lastOld = previousBlocks[previousBlocks.length - 1];
108
+ const lastNew = blocks[lastJ];
109
+ if (lastNew && lastOld && !map.has(lastJ) && lastNew.type === lastOld.type) {
110
+ const usedIds = new Set(map.values());
111
+ if (!usedIds.has(lastOld.id)) map.set(lastJ, lastOld.id);
112
+ }
113
+ return map;
137
114
  }
138
- function parseAttributes(attrStr) {
139
- const result = {};
140
- const re = /(\w[\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
141
- let m;
142
- while ((m = re.exec(attrStr)) !== null) {
143
- result[m[1]] = m[2] ?? m[3] ?? m[4] ?? true;
115
+ function contentKey({ id: _id, ...rest }) {
116
+ return JSON.stringify(rest);
117
+ }
118
+ function lcsIndices(oldKeys, newKeys) {
119
+ const m = oldKeys.length;
120
+ const n = newKeys.length;
121
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
122
+ for (let i2 = 1; i2 <= m; i2++) {
123
+ for (let j2 = 1; j2 <= n; j2++) {
124
+ dp[i2][j2] = oldKeys[i2 - 1] === newKeys[j2 - 1] ? dp[i2 - 1][j2 - 1] + 1 : Math.max(dp[i2 - 1][j2], dp[i2][j2 - 1]);
125
+ }
144
126
  }
145
- return result;
127
+ const pairs = [];
128
+ let i = m;
129
+ let j = n;
130
+ while (i > 0 && j > 0) {
131
+ if (oldKeys[i - 1] === newKeys[j - 1]) {
132
+ pairs.unshift([i - 1, j - 1]);
133
+ i--;
134
+ j--;
135
+ } else if (dp[i - 1][j] >= dp[i][j - 1]) {
136
+ i--;
137
+ } else {
138
+ j--;
139
+ }
140
+ }
141
+ return pairs;
146
142
  }
147
143
  function parseInline(md) {
148
144
  let plainText = "";
@@ -216,6 +212,13 @@ function parseInline(md) {
216
212
  }
217
213
  return { text: plainText, styles };
218
214
  }
215
+ function parseAttributes(attrStr) {
216
+ const result = {};
217
+ const re = /(\w[\w-]*)(?:=(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
218
+ let m;
219
+ while ((m = re.exec(attrStr)) !== null) result[m[1]] = m[2] ?? m[3] ?? m[4] ?? true;
220
+ return result;
221
+ }
219
222
  const MARKERS = {
220
223
  bold: { open: "**", close: "**" },
221
224
  italic: { open: "*", close: "*" },
@@ -223,14 +226,18 @@ const MARKERS = {
223
226
  code: { open: "`", close: "`" }
224
227
  };
225
228
  const STANDARD_FIELDS = /* @__PURE__ */ new Set(["id", "text", "type", "styles", "editable"]);
226
- function blocksToMarkdown(blocks) {
227
- const lines = [];
229
+ const isListType = (type) => type === "li" || type === "ol";
230
+ function blocksToMarkdown(blocks, options = {}) {
231
+ const parts = [];
228
232
  let olCounter = 0;
229
233
  for (const block of blocks) {
230
234
  if (block.type === "code") {
231
- lines.push("```");
232
- lines.push(block.text);
233
- lines.push("```");
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" });
234
241
  olCounter = 0;
235
242
  continue;
236
243
  }
@@ -242,9 +249,9 @@ function blocksToMarkdown(blocks) {
242
249
  }).join(" ");
243
250
  const prefix = attrStr ? ` ${attrStr}` : "";
244
251
  if (block.editable === false || !block.text) {
245
- lines.push(`<${block.type}${prefix}/>`);
252
+ parts.push({ md: `<${block.type}${prefix}/>`, type: block.type });
246
253
  } else {
247
- lines.push(`<${block.type}${prefix}>${renderInline(block.text, block.styles ?? [])}</${block.type}>`);
254
+ parts.push({ md: `<${block.type}${prefix}>${renderInline(block.text, block.styles ?? [])}</${block.type}>`, type: block.type });
248
255
  }
249
256
  olCounter = 0;
250
257
  continue;
@@ -252,37 +259,46 @@ function blocksToMarkdown(blocks) {
252
259
  const inline = renderInline(block.text, block.styles ?? []);
253
260
  switch (block.type) {
254
261
  case "h1":
255
- lines.push(`# ${inline}`);
262
+ parts.push({ md: `# ${inline}`, type: "h1" });
256
263
  olCounter = 0;
257
264
  break;
258
265
  case "h2":
259
- lines.push(`## ${inline}`);
266
+ parts.push({ md: `## ${inline}`, type: "h2" });
260
267
  olCounter = 0;
261
268
  break;
262
269
  case "h3":
263
- lines.push(`### ${inline}`);
270
+ parts.push({ md: `### ${inline}`, type: "h3" });
264
271
  olCounter = 0;
265
272
  break;
266
273
  case "li":
267
- lines.push(`- ${inline}`);
274
+ parts.push({ md: `- ${inline}`, type: "li" });
268
275
  olCounter = 0;
269
276
  break;
270
277
  case "ol":
271
- lines.push(`${++olCounter}. ${inline}`);
278
+ parts.push({ md: `${++olCounter}. ${inline}`, type: "ol" });
272
279
  break;
273
280
  default:
274
281
  if (block.type) {
275
- lines.push(`:::${block.type}`);
276
- lines.push(inline);
277
- lines.push(":::");
282
+ parts.push({ md: `:::${block.type}
283
+ ${inline}
284
+ :::`, type: block.type });
278
285
  } else {
279
- lines.push(inline);
286
+ parts.push({ md: inline, type: void 0 });
280
287
  }
281
288
  olCounter = 0;
282
289
  break;
283
290
  }
284
291
  }
285
- return lines.join("\n");
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;
286
302
  }
287
303
  function escapeMarkdown(text) {
288
304
  return text.replace(/[\\*_`\[\]]/g, "\\$&");
@@ -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
- private blocksIds;
148
- private cachedBlocksJson;
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
- tickStarted: boolean;
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 const TextEditorView: DefineComponent< {
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
- __publicField(this, "blocksIds", "");
61
- __publicField(this, "cachedBlocksJson", "");
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.blocksIds = this.store.blocks.map((item) => item.id).join(",");
86
+ this.cachedBlockIds = this.store.blocks.map((item) => item.id);
69
87
  }
70
88
  blockIdsChanged() {
71
- return this.blocksIds !== this.store.blocks.map((item) => item.id).join(",");
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 = JSON.parse(JSON.stringify(this.store.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 = JSON.parse(JSON.stringify(this.store.currentBlock));
84
- if (lastAction && lastAction.type === type && ((_a = lastAction.blocks[0]) == null ? void 0 : _a.id) === currentBlock.id && (lastAction.type === "insertText" || lastAction.type === "deleteContentBackward" || lastAction.type === "deleteContentForward")) {
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: JSON.parse(blocksJson), selection, fullUpdate });
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 = JSON.parse(this.cachedBlocksJson);
123
+ lastAction.blocks = this.cachedBlocks;
98
124
  }
125
+ this.cachedBlocks = cloneBlocks(this.store.blocks);
99
126
  this.cacheBlockIds();
100
127
  }
101
- this.cachedBlocksJson = blocksJson;
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 === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;").replace(/\n/g, "<br>");
841
+ const escapeAttr = (value) => value.replace(/[&<>"]/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;");
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 text2 = store.blocks[startIndex].text.slice(start.offset, end.offset);
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
- { type: store.blocks[startIndex].type, text: startText },
659
- ...store.blocks.slice(startIndex + 1, endIndex),
660
- { type: store.blocks[endIndex].type, text: endText }
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 insertText = (text, type) => {
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
- const parseHtml = (node, type) => {
700
- var _a, _b, _c, _d;
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
- return;
947
+ store.insertText(lines[i]);
714
948
  }
715
- for (let child of node.children) {
716
- if (child.tagName === "DIV") {
717
- parseHtml(child, type);
718
- } else if (child.tagName === "UL" || child.tagName === "OL") {
719
- for (let li of child.children) {
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
- parseHtml(dom.body);
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
- insertText(text);
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
- onCopy: _cache[1] || (_cache[1] = //@ts-ignore
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[2] || (_cache[2] = //@ts-ignore
1209
+ onPaste: _cache[3] || (_cache[3] = //@ts-ignore
955
1210
  (...args) => unref(onPaste) && unref(onPaste)(...args)),
956
- onCut: _cache[3] || (_cache[3] = //@ts-ignore
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 TextEditorView,
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.28",
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
  }