vuewrite 0.0.1
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/.vscode/extensions.json +3 -0
- package/README.md +61 -0
- package/dist/vuewrite.d.ts +117 -0
- package/dist/vuewrite.js +631 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# VueWrite
|
|
2
|
+
|
|
3
|
+
VueWrite is another text editor that takes full advantage of Vue3's features.
|
|
4
|
+
It contains no pre-made styles and blocks as its main goal is complete customization and extension
|
|
5
|
+
|
|
6
|
+
## Demo
|
|
7
|
+
|
|
8
|
+
You can watch the demo [here](https://vuewrite.easix.ru)
|
|
9
|
+
|
|
10
|
+
## Quickstart
|
|
11
|
+
|
|
12
|
+
```vue
|
|
13
|
+
<template>
|
|
14
|
+
<TextEditor
|
|
15
|
+
:store="textEditorStore"
|
|
16
|
+
class="text-editor"
|
|
17
|
+
:decorator="decorator"
|
|
18
|
+
@keydown="onKeyDown"
|
|
19
|
+
/>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
|
|
24
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
25
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
26
|
+
if (e.code === "KeyB") {
|
|
27
|
+
textEditorStore.toggleStyle("bold")
|
|
28
|
+
}
|
|
29
|
+
if (e.code === "KeyI") {
|
|
30
|
+
textEditorStore.toggleStyle("italic")
|
|
31
|
+
}
|
|
32
|
+
if (e.code === "KeyU") {
|
|
33
|
+
textEditorStore.toggleStyle("underline")
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const decorator = (style: Style) => {
|
|
39
|
+
if (style.style === 'bold' || style.style === "underline" || style.style === "italic") {
|
|
40
|
+
return { class: style.style }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<style lang="css">
|
|
47
|
+
.text-editor {
|
|
48
|
+
white-space: pre-wrap;
|
|
49
|
+
}
|
|
50
|
+
.text-editor .bold {
|
|
51
|
+
font-weight: 700
|
|
52
|
+
}
|
|
53
|
+
.text-editor .italic {
|
|
54
|
+
font-style: italic
|
|
55
|
+
}
|
|
56
|
+
.text-editor .underline {
|
|
57
|
+
text-decoration: underline
|
|
58
|
+
}
|
|
59
|
+
</style>
|
|
60
|
+
|
|
61
|
+
```
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ComponentOptionsMixin } from 'vue';
|
|
2
|
+
import { DefineComponent } from 'vue';
|
|
3
|
+
import { ExtractPropTypes } from 'vue';
|
|
4
|
+
import { HTMLAttributes } from 'vue';
|
|
5
|
+
import { PropType } from 'vue';
|
|
6
|
+
import { PublicProps } from 'vue';
|
|
7
|
+
|
|
8
|
+
declare type __VLS_NonUndefinedable<T> = T extends undefined ? never : T;
|
|
9
|
+
|
|
10
|
+
declare type __VLS_TypePropsToRuntimeProps<T> = {
|
|
11
|
+
[K in keyof T]-?: {} extends Pick<T, K> ? {
|
|
12
|
+
type: PropType<__VLS_NonUndefinedable<T[K]>>;
|
|
13
|
+
} : {
|
|
14
|
+
type: PropType<T[K]>;
|
|
15
|
+
required: true;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
declare type __VLS_WithTemplateSlots<T, S> = T & {
|
|
20
|
+
new (): {
|
|
21
|
+
$slots: S;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export declare type Block = {
|
|
26
|
+
id: string;
|
|
27
|
+
text: string;
|
|
28
|
+
type?: string;
|
|
29
|
+
styles: Style[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export declare type Decorator = (style: Style) => HTMLAttributes | undefined;
|
|
33
|
+
|
|
34
|
+
export declare type Style = {
|
|
35
|
+
start: number;
|
|
36
|
+
end: number;
|
|
37
|
+
style: string;
|
|
38
|
+
meta?: any;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export declare const TextEditor: __VLS_WithTemplateSlots<DefineComponent<__VLS_TypePropsToRuntimeProps<{
|
|
42
|
+
store: TextEditorStore;
|
|
43
|
+
placeholder?: string | undefined;
|
|
44
|
+
decorator?: Decorator | undefined;
|
|
45
|
+
}>, {
|
|
46
|
+
store: TextEditorStore;
|
|
47
|
+
}, unknown, {}, {}, ComponentOptionsMixin, ComponentOptionsMixin, {
|
|
48
|
+
keydown: (...args: any[]) => void;
|
|
49
|
+
}, string, PublicProps, Readonly<ExtractPropTypes<__VLS_TypePropsToRuntimeProps<{
|
|
50
|
+
store: TextEditorStore;
|
|
51
|
+
placeholder?: string | undefined;
|
|
52
|
+
decorator?: Decorator | undefined;
|
|
53
|
+
}>>> & {
|
|
54
|
+
onKeydown?: ((...args: any[]) => any) | undefined;
|
|
55
|
+
}, {}, {}>, {
|
|
56
|
+
placeholder?(_: {}): any;
|
|
57
|
+
}>;
|
|
58
|
+
|
|
59
|
+
export declare class TextEditorStore {
|
|
60
|
+
blocks: {
|
|
61
|
+
id: string;
|
|
62
|
+
text: string;
|
|
63
|
+
type?: string | undefined;
|
|
64
|
+
styles: {
|
|
65
|
+
start: number;
|
|
66
|
+
end: number;
|
|
67
|
+
style: string;
|
|
68
|
+
meta?: any;
|
|
69
|
+
}[];
|
|
70
|
+
}[];
|
|
71
|
+
selection: {
|
|
72
|
+
anchor: {
|
|
73
|
+
blockId: string;
|
|
74
|
+
offset: number;
|
|
75
|
+
};
|
|
76
|
+
focus: {
|
|
77
|
+
blockId: string;
|
|
78
|
+
offset: number;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
private _isCollapsed;
|
|
82
|
+
get isCollapsed(): boolean;
|
|
83
|
+
private _currentBlock;
|
|
84
|
+
get currentBlock(): {
|
|
85
|
+
id: string;
|
|
86
|
+
text: string;
|
|
87
|
+
type?: string | undefined;
|
|
88
|
+
styles: {
|
|
89
|
+
start: number;
|
|
90
|
+
end: number;
|
|
91
|
+
style: string;
|
|
92
|
+
meta?: any;
|
|
93
|
+
}[];
|
|
94
|
+
} | null;
|
|
95
|
+
moveOffset(newOffset: number): void;
|
|
96
|
+
onInput(_e: Event): void;
|
|
97
|
+
addNewLine(): void;
|
|
98
|
+
insertText(data: string): void;
|
|
99
|
+
get startAndEnd(): [{
|
|
100
|
+
blockId: string;
|
|
101
|
+
offset: number;
|
|
102
|
+
}, {
|
|
103
|
+
blockId: string;
|
|
104
|
+
offset: number;
|
|
105
|
+
}, number, number];
|
|
106
|
+
private moveStyles;
|
|
107
|
+
deleteSelected(): void;
|
|
108
|
+
private _currentStyles;
|
|
109
|
+
get currentStyles(): Map<string, Style>;
|
|
110
|
+
applyStyle(_style: string, meta?: any): void;
|
|
111
|
+
removeStyleAt(block: Block, _start: number, _end: number, _style?: string): void;
|
|
112
|
+
removeStyle(_style: string): void;
|
|
113
|
+
removeAllStyles(): void;
|
|
114
|
+
toggleStyle(style: string): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { }
|
package/dist/vuewrite.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
|
3
|
+
var __publicField = (obj, key, value) => {
|
|
4
|
+
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
5
|
+
return value;
|
|
6
|
+
};
|
|
7
|
+
import { getCurrentScope, onScopeDispose, unref, watch, reactive, computed, defineComponent, getCurrentInstance, openBlock, createBlock, resolveDynamicComponent, createElementBlock, normalizeProps, mergeProps, h, nextTick, useSlots, ref, Fragment, renderList, renderSlot, createCommentVNode } from "vue";
|
|
8
|
+
function tryOnScopeDispose(fn) {
|
|
9
|
+
if (getCurrentScope()) {
|
|
10
|
+
onScopeDispose(fn);
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
function toValue(r) {
|
|
16
|
+
return typeof r === "function" ? r() : unref(r);
|
|
17
|
+
}
|
|
18
|
+
const isClient = typeof window !== "undefined" && typeof document !== "undefined";
|
|
19
|
+
typeof WorkerGlobalScope !== "undefined" && globalThis instanceof WorkerGlobalScope;
|
|
20
|
+
const toString = Object.prototype.toString;
|
|
21
|
+
const isObject = (val) => toString.call(val) === "[object Object]";
|
|
22
|
+
const noop = () => {
|
|
23
|
+
};
|
|
24
|
+
function unrefElement(elRef) {
|
|
25
|
+
var _a;
|
|
26
|
+
const plain = toValue(elRef);
|
|
27
|
+
return (_a = plain == null ? void 0 : plain.$el) != null ? _a : plain;
|
|
28
|
+
}
|
|
29
|
+
const defaultWindow = isClient ? window : void 0;
|
|
30
|
+
function useEventListener(...args) {
|
|
31
|
+
let target;
|
|
32
|
+
let events;
|
|
33
|
+
let listeners;
|
|
34
|
+
let options;
|
|
35
|
+
if (typeof args[0] === "string" || Array.isArray(args[0])) {
|
|
36
|
+
[events, listeners, options] = args;
|
|
37
|
+
target = defaultWindow;
|
|
38
|
+
} else {
|
|
39
|
+
[target, events, listeners, options] = args;
|
|
40
|
+
}
|
|
41
|
+
if (!target)
|
|
42
|
+
return noop;
|
|
43
|
+
if (!Array.isArray(events))
|
|
44
|
+
events = [events];
|
|
45
|
+
if (!Array.isArray(listeners))
|
|
46
|
+
listeners = [listeners];
|
|
47
|
+
const cleanups = [];
|
|
48
|
+
const cleanup = () => {
|
|
49
|
+
cleanups.forEach((fn) => fn());
|
|
50
|
+
cleanups.length = 0;
|
|
51
|
+
};
|
|
52
|
+
const register = (el, event, listener, options2) => {
|
|
53
|
+
el.addEventListener(event, listener, options2);
|
|
54
|
+
return () => el.removeEventListener(event, listener, options2);
|
|
55
|
+
};
|
|
56
|
+
const stopWatch = watch(
|
|
57
|
+
() => [unrefElement(target), toValue(options)],
|
|
58
|
+
([el, options2]) => {
|
|
59
|
+
cleanup();
|
|
60
|
+
if (!el)
|
|
61
|
+
return;
|
|
62
|
+
const optionsClone = isObject(options2) ? { ...options2 } : options2;
|
|
63
|
+
cleanups.push(
|
|
64
|
+
...events.flatMap((event) => {
|
|
65
|
+
return listeners.map((listener) => register(el, event, listener, optionsClone));
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
{ immediate: true, flush: "post" }
|
|
70
|
+
);
|
|
71
|
+
const stop = () => {
|
|
72
|
+
stopWatch();
|
|
73
|
+
cleanup();
|
|
74
|
+
};
|
|
75
|
+
tryOnScopeDispose(stop);
|
|
76
|
+
return stop;
|
|
77
|
+
}
|
|
78
|
+
const findParent = (el, callback) => {
|
|
79
|
+
if (!el)
|
|
80
|
+
return null;
|
|
81
|
+
if (el.nodeType === Node.ELEMENT_NODE) {
|
|
82
|
+
if (callback(el))
|
|
83
|
+
return el;
|
|
84
|
+
}
|
|
85
|
+
if (!el.parentElement)
|
|
86
|
+
return null;
|
|
87
|
+
return findParent(el.parentElement, callback);
|
|
88
|
+
};
|
|
89
|
+
const calcOffsetToNode = (parent, target) => {
|
|
90
|
+
let offset = 0;
|
|
91
|
+
for (let child of parent.childNodes) {
|
|
92
|
+
if (child.nodeName === "BR")
|
|
93
|
+
continue;
|
|
94
|
+
const ch = child.nodeType === Node.TEXT_NODE ? child : child.childNodes[0];
|
|
95
|
+
if (ch === target)
|
|
96
|
+
return offset;
|
|
97
|
+
offset += ch.length;
|
|
98
|
+
}
|
|
99
|
+
return offset;
|
|
100
|
+
};
|
|
101
|
+
const calcNodeByOffset = (parent, offset) => {
|
|
102
|
+
let currentOffset = offset;
|
|
103
|
+
if (offset === 0)
|
|
104
|
+
return [parent, 0];
|
|
105
|
+
for (let child of parent.childNodes) {
|
|
106
|
+
if (child.nodeName === "BR")
|
|
107
|
+
continue;
|
|
108
|
+
const ch = child.nodeType === Node.TEXT_NODE ? child : child.childNodes[0];
|
|
109
|
+
if (currentOffset - ch.length <= 0) {
|
|
110
|
+
return [ch, currentOffset];
|
|
111
|
+
}
|
|
112
|
+
currentOffset -= ch.length;
|
|
113
|
+
}
|
|
114
|
+
return [parent, 0];
|
|
115
|
+
};
|
|
116
|
+
const clamp = (val, min, max) => {
|
|
117
|
+
if (val > max)
|
|
118
|
+
return max;
|
|
119
|
+
if (val < min)
|
|
120
|
+
return min;
|
|
121
|
+
return val;
|
|
122
|
+
};
|
|
123
|
+
const isEqual = (a, b) => {
|
|
124
|
+
if (a === b)
|
|
125
|
+
return true;
|
|
126
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
|
|
127
|
+
return false;
|
|
128
|
+
if (Array.isArray(a)) {
|
|
129
|
+
if (!Array.isArray(b) || a.length !== b.length)
|
|
130
|
+
return false;
|
|
131
|
+
for (let i = 0; i < a.length; i++) {
|
|
132
|
+
if (!isEqual(a[i], b[i]))
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
for (let key in a) {
|
|
138
|
+
if (!isEqual(a[key], b[key]))
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
class TextEditorStore {
|
|
144
|
+
constructor() {
|
|
145
|
+
__publicField(this, "blocks", reactive([{ id: uid(), text: "", styles: [] }]));
|
|
146
|
+
__publicField(this, "selection", reactive({
|
|
147
|
+
anchor: { blockId: this.blocks[0].id, offset: 0 },
|
|
148
|
+
focus: { blockId: this.blocks[0].id, offset: 0 }
|
|
149
|
+
}));
|
|
150
|
+
__publicField(this, "_isCollapsed", computed(() => {
|
|
151
|
+
return this.selection.anchor.blockId === this.selection.focus.blockId && this.selection.anchor.offset === this.selection.focus.offset;
|
|
152
|
+
}));
|
|
153
|
+
__publicField(this, "_currentBlock", computed(() => {
|
|
154
|
+
if (this.selection.anchor.blockId !== this.selection.focus.blockId)
|
|
155
|
+
return null;
|
|
156
|
+
return this.blocks.find((item) => item.id === this.selection.anchor.blockId) ?? null;
|
|
157
|
+
}));
|
|
158
|
+
__publicField(this, "_currentStyles", computed(() => {
|
|
159
|
+
const [start, end, startIndex, endIndex] = this.startAndEnd;
|
|
160
|
+
const styles = /* @__PURE__ */ new Map();
|
|
161
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
162
|
+
for (let style of this.blocks[i].styles) {
|
|
163
|
+
if (i === startIndex && start.offset < style.start)
|
|
164
|
+
continue;
|
|
165
|
+
if (i === endIndex && end.offset > style.end)
|
|
166
|
+
continue;
|
|
167
|
+
styles.set(style.style, style);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return styles;
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
get isCollapsed() {
|
|
174
|
+
return this._isCollapsed.value;
|
|
175
|
+
}
|
|
176
|
+
get currentBlock() {
|
|
177
|
+
return this._currentBlock.value;
|
|
178
|
+
}
|
|
179
|
+
moveOffset(newOffset) {
|
|
180
|
+
const delta = newOffset - this.selection.anchor.offset;
|
|
181
|
+
if (delta === 0)
|
|
182
|
+
return;
|
|
183
|
+
this.moveStyles(this.currentBlock, this.selection.focus.offset, -delta);
|
|
184
|
+
this.selection.anchor.offset = newOffset;
|
|
185
|
+
this.selection.focus.offset = newOffset;
|
|
186
|
+
}
|
|
187
|
+
onInput(_e) {
|
|
188
|
+
const ev = _e;
|
|
189
|
+
ev.preventDefault();
|
|
190
|
+
const collapsed = this.isCollapsed;
|
|
191
|
+
if (!collapsed)
|
|
192
|
+
this.deleteSelected();
|
|
193
|
+
const block = this.currentBlock;
|
|
194
|
+
if (!block)
|
|
195
|
+
return;
|
|
196
|
+
if ((ev.inputType === "deleteContentBackward" || ev.inputType === "deleteContentForward") && collapsed) {
|
|
197
|
+
const blockIndex = this.blocks.findIndex((item) => item.id === this.selection.anchor.blockId);
|
|
198
|
+
if (ev.inputType === "deleteContentBackward") {
|
|
199
|
+
if (this.selection.anchor.offset === 0) {
|
|
200
|
+
if (blockIndex > 0) {
|
|
201
|
+
this.selection.anchor.blockId = this.blocks[blockIndex - 1].id;
|
|
202
|
+
this.selection.focus.blockId = this.blocks[blockIndex - 1].id;
|
|
203
|
+
this.selection.anchor.offset = this.blocks[blockIndex - 1].text.length;
|
|
204
|
+
this.selection.focus.offset = this.blocks[blockIndex - 1].text.length;
|
|
205
|
+
this.blocks.splice(blockIndex, 1);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
const offset = Math.max(0, this.selection.focus.offset - 1);
|
|
209
|
+
block.text = block.text.slice(0, offset) + block.text.slice(this.selection.focus.offset);
|
|
210
|
+
this.moveOffset(offset);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (ev.inputType === "deleteContentForward") {
|
|
214
|
+
if (this.selection.anchor.offset === this.blocks[blockIndex].text.length) {
|
|
215
|
+
this.blocks.splice(blockIndex + 1, 1);
|
|
216
|
+
} else {
|
|
217
|
+
block.text = block.text.slice(0, this.selection.focus.offset) + block.text.slice(this.selection.focus.offset + 1);
|
|
218
|
+
this.moveStyles(block, this.selection.focus.offset + 1, 1);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (ev.inputType === "insertText") {
|
|
223
|
+
this.insertText(ev.data);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
addNewLine() {
|
|
227
|
+
this.deleteSelected();
|
|
228
|
+
if (!this.currentBlock)
|
|
229
|
+
return;
|
|
230
|
+
const endText = this.currentBlock.text.slice(this.selection.anchor.offset);
|
|
231
|
+
this.currentBlock.text = this.currentBlock.text.slice(0, this.selection.anchor.offset);
|
|
232
|
+
const block = { id: uid(), text: endText || "", styles: [] };
|
|
233
|
+
const index = this.blocks.findIndex((item) => item.id === this.selection.anchor.blockId);
|
|
234
|
+
this.blocks.splice(index + 1, 0, block);
|
|
235
|
+
this.selection.anchor = { blockId: block.id, offset: 0 };
|
|
236
|
+
this.selection.focus = { blockId: block.id, offset: 0 };
|
|
237
|
+
}
|
|
238
|
+
insertText(data) {
|
|
239
|
+
const block = this.currentBlock;
|
|
240
|
+
if (!block)
|
|
241
|
+
return;
|
|
242
|
+
block.text = block.text.slice(0, this.selection.focus.offset) + data + block.text.slice(this.selection.focus.offset);
|
|
243
|
+
this.moveOffset(clamp(this.selection.focus.offset + data.length, 0, block.text.length));
|
|
244
|
+
}
|
|
245
|
+
get startAndEnd() {
|
|
246
|
+
const a = this.blocks.findIndex((block) => block.id === this.selection.anchor.blockId);
|
|
247
|
+
const f = this.blocks.findIndex((block) => block.id === this.selection.focus.blockId);
|
|
248
|
+
if (a < f || a === f && this.selection.anchor.offset < this.selection.focus.offset) {
|
|
249
|
+
return [this.selection.anchor, this.selection.focus, a, f];
|
|
250
|
+
} else {
|
|
251
|
+
return [this.selection.focus, this.selection.anchor, f, a];
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
moveStyles(block, offset, delta) {
|
|
255
|
+
for (let i = 0; i < block.styles.length; i++) {
|
|
256
|
+
const style = block.styles[i];
|
|
257
|
+
if (style.start >= offset)
|
|
258
|
+
style.start -= delta;
|
|
259
|
+
if (style.end >= offset)
|
|
260
|
+
style.end -= delta;
|
|
261
|
+
if (style.end <= style.start) {
|
|
262
|
+
block.styles.splice(i, 1);
|
|
263
|
+
i--;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
deleteSelected() {
|
|
268
|
+
if (this.isCollapsed)
|
|
269
|
+
return;
|
|
270
|
+
const [start, end, startIndex, endIndex] = this.startAndEnd;
|
|
271
|
+
const startText = this.blocks[startIndex].text.slice(0, start.offset);
|
|
272
|
+
const endText = this.blocks[endIndex].text.slice(end.offset);
|
|
273
|
+
this.removeAllStyles();
|
|
274
|
+
const delta = end.offset - (startIndex === endIndex ? start.offset : 0);
|
|
275
|
+
this.moveStyles(this.blocks[endIndex], end.offset, delta);
|
|
276
|
+
if (this.selection.anchor.blockId !== this.selection.focus.blockId) {
|
|
277
|
+
this.blocks.splice(startIndex + 1, endIndex - startIndex);
|
|
278
|
+
}
|
|
279
|
+
if (start !== this.selection.anchor) {
|
|
280
|
+
this.selection.anchor = { ...start };
|
|
281
|
+
}
|
|
282
|
+
if (start !== this.selection.focus) {
|
|
283
|
+
this.selection.focus = { ...start };
|
|
284
|
+
}
|
|
285
|
+
this.blocks[startIndex].text = startText + endText;
|
|
286
|
+
}
|
|
287
|
+
get currentStyles() {
|
|
288
|
+
return this._currentStyles.value;
|
|
289
|
+
}
|
|
290
|
+
applyStyle(_style, meta) {
|
|
291
|
+
const [start, end, startIndex, endIndex] = this.startAndEnd;
|
|
292
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
293
|
+
const block = this.blocks[i];
|
|
294
|
+
const _start = i === startIndex ? start.offset : 0;
|
|
295
|
+
const _end = i === endIndex ? end.offset : block.text.length;
|
|
296
|
+
let createNewStyle = true;
|
|
297
|
+
for (let style of block.styles) {
|
|
298
|
+
if (style.style !== _style)
|
|
299
|
+
continue;
|
|
300
|
+
if (style.start > _end || style.end < _start)
|
|
301
|
+
continue;
|
|
302
|
+
if (meta && !isEqual(meta, style.meta) && (style.start !== _start || style.end !== _end)) {
|
|
303
|
+
if (_start === style.end || _end === style.start)
|
|
304
|
+
continue;
|
|
305
|
+
this.removeStyleAt(block, _start, _end, _style);
|
|
306
|
+
} else {
|
|
307
|
+
style.start = Math.min(style.start, _start);
|
|
308
|
+
style.end = Math.max(style.end, _end);
|
|
309
|
+
createNewStyle = false;
|
|
310
|
+
style.meta = meta;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (createNewStyle) {
|
|
314
|
+
const newStyle = {
|
|
315
|
+
start: i === startIndex ? start.offset : 0,
|
|
316
|
+
end: i === endIndex ? end.offset : block.text.length,
|
|
317
|
+
style: _style,
|
|
318
|
+
meta
|
|
319
|
+
};
|
|
320
|
+
block.styles.push(newStyle);
|
|
321
|
+
block.styles.sort((a, b) => a.start - b.start);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
removeStyleAt(block, _start, _end, _style) {
|
|
326
|
+
const removeAll = typeof _style !== "string";
|
|
327
|
+
for (let i = 0; i < block.styles.length; i++) {
|
|
328
|
+
const style = block.styles[i];
|
|
329
|
+
if (!removeAll && style.style !== _style)
|
|
330
|
+
continue;
|
|
331
|
+
if (style.start > _end || style.end < _start)
|
|
332
|
+
continue;
|
|
333
|
+
if (style.start >= _start && style.end <= _end) {
|
|
334
|
+
block.styles.splice(i, 1);
|
|
335
|
+
i--;
|
|
336
|
+
} else if (style.start < _start && style.end > _end) {
|
|
337
|
+
block.styles.push({ ...style, start: _end });
|
|
338
|
+
style.end = _start;
|
|
339
|
+
} else if (style.start < _start) {
|
|
340
|
+
style.end = _start;
|
|
341
|
+
} else if (style.end > _end) {
|
|
342
|
+
style.start = _end;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
removeStyle(_style) {
|
|
347
|
+
const [start, end, startIndex, endIndex] = this.startAndEnd;
|
|
348
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
349
|
+
const block = this.blocks[i];
|
|
350
|
+
const _start = i === startIndex ? start.offset : 0;
|
|
351
|
+
const _end = i === endIndex ? end.offset : block.text.length;
|
|
352
|
+
this.removeStyleAt(block, _start, _end, _style);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
removeAllStyles() {
|
|
356
|
+
const [start, end, startIndex, endIndex] = this.startAndEnd;
|
|
357
|
+
for (let i = startIndex; i <= endIndex; i++) {
|
|
358
|
+
const block = this.blocks[i];
|
|
359
|
+
if (i > startIndex && i < endIndex) {
|
|
360
|
+
block.styles.length = 0;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const _start = i === startIndex ? start.offset : 0;
|
|
364
|
+
const _end = i === endIndex ? end.offset : block.text.length;
|
|
365
|
+
this.removeStyleAt(block, _start, _end);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
toggleStyle(style) {
|
|
369
|
+
if (this.currentStyles.has(style)) {
|
|
370
|
+
this.removeStyle(style);
|
|
371
|
+
} else {
|
|
372
|
+
this.applyStyle(style);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
let uidCounter = 0;
|
|
377
|
+
const uid = () => (uidCounter++).toString();
|
|
378
|
+
const _sfc_main$1 = /* @__PURE__ */ defineComponent({
|
|
379
|
+
__name: "TextEditorBlock",
|
|
380
|
+
props: {
|
|
381
|
+
block: {},
|
|
382
|
+
slots: {},
|
|
383
|
+
decorator: { type: Function }
|
|
384
|
+
},
|
|
385
|
+
emits: ["postrender"],
|
|
386
|
+
setup(__props, { emit: __emit }) {
|
|
387
|
+
const props = __props;
|
|
388
|
+
const emit = __emit;
|
|
389
|
+
const slot = computed(() => {
|
|
390
|
+
if (!props.block.type)
|
|
391
|
+
return null;
|
|
392
|
+
return props.slots[props.block.type] ?? null;
|
|
393
|
+
});
|
|
394
|
+
const blockProps = {
|
|
395
|
+
"data-block-id": props.block.id
|
|
396
|
+
};
|
|
397
|
+
const renderBlockPart = (text, styles) => {
|
|
398
|
+
if (!props.decorator)
|
|
399
|
+
return text;
|
|
400
|
+
const attrs = {};
|
|
401
|
+
for (let style of styles) {
|
|
402
|
+
const partProps = props.decorator(style);
|
|
403
|
+
if (!partProps)
|
|
404
|
+
continue;
|
|
405
|
+
const { class: _class, style: _style, ...otherProps } = partProps;
|
|
406
|
+
Object.assign(attrs, otherProps);
|
|
407
|
+
if (_class) {
|
|
408
|
+
attrs.class = attrs.class ? attrs.class + " " + _class : _class;
|
|
409
|
+
}
|
|
410
|
+
if (_style) {
|
|
411
|
+
attrs.style = attrs.style ? attrs.style + " " + _style : _style;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (Object.keys(attrs).length === 0)
|
|
415
|
+
return text;
|
|
416
|
+
return h("span", attrs, text);
|
|
417
|
+
};
|
|
418
|
+
const instance = getCurrentInstance();
|
|
419
|
+
const getRef = () => {
|
|
420
|
+
if (!instance)
|
|
421
|
+
return null;
|
|
422
|
+
const el = instance.vnode.el;
|
|
423
|
+
if (!el)
|
|
424
|
+
return null;
|
|
425
|
+
if (el.nodeType === Node.TEXT_NODE && el.nextSibling !== null) {
|
|
426
|
+
return el.nextSibling;
|
|
427
|
+
}
|
|
428
|
+
return el;
|
|
429
|
+
};
|
|
430
|
+
const cacheNodes = [];
|
|
431
|
+
let cacheEl = null;
|
|
432
|
+
const cleanTree = (count) => {
|
|
433
|
+
const el = getRef();
|
|
434
|
+
if (!el)
|
|
435
|
+
return;
|
|
436
|
+
if (el === cacheEl && cacheNodes.length > 0) {
|
|
437
|
+
el.prepend(cacheNodes[0]);
|
|
438
|
+
el.append(cacheNodes[1]);
|
|
439
|
+
}
|
|
440
|
+
nextTick(() => {
|
|
441
|
+
var _a;
|
|
442
|
+
cacheEl = getRef();
|
|
443
|
+
if (el === cacheEl) {
|
|
444
|
+
if (el.childNodes.length > count + 2) {
|
|
445
|
+
for (let i = 0; i < el.childNodes.length; i++) {
|
|
446
|
+
const child = el.childNodes[i];
|
|
447
|
+
if (i === 0 && child.nodeType === Node.TEXT_NODE && child.textContent === "")
|
|
448
|
+
continue;
|
|
449
|
+
if ("__vnode" in child)
|
|
450
|
+
continue;
|
|
451
|
+
if (child.nodeType === Node.TEXT_NODE && ((_a = child.textContent) == null ? void 0 : _a.endsWith("\n")))
|
|
452
|
+
continue;
|
|
453
|
+
el.removeChild(child);
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (cacheEl) {
|
|
459
|
+
cacheNodes[0] = cacheEl.childNodes[0];
|
|
460
|
+
cacheNodes[1] = cacheEl.childNodes[cacheEl.childNodes.length - 1];
|
|
461
|
+
}
|
|
462
|
+
emit("postrender");
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
const content = () => {
|
|
466
|
+
const block = props.block;
|
|
467
|
+
if (block.text.length === 0) {
|
|
468
|
+
cleanTree(1);
|
|
469
|
+
return [h("br")];
|
|
470
|
+
}
|
|
471
|
+
let text = block.text;
|
|
472
|
+
if (text.endsWith("\n")) {
|
|
473
|
+
text = text + "\n";
|
|
474
|
+
}
|
|
475
|
+
const markers = [];
|
|
476
|
+
for (let style of block.styles) {
|
|
477
|
+
markers.push([style.start, style]);
|
|
478
|
+
markers.push([style.end, style]);
|
|
479
|
+
}
|
|
480
|
+
markers.sort((a, b) => a[0] - b[0]);
|
|
481
|
+
let currentIndex = 0;
|
|
482
|
+
const activeStyles = /* @__PURE__ */ new Set();
|
|
483
|
+
const blocks = [];
|
|
484
|
+
for (let marker of markers) {
|
|
485
|
+
if (currentIndex !== marker[0]) {
|
|
486
|
+
const str = text.slice(currentIndex, marker[0]);
|
|
487
|
+
blocks.push(renderBlockPart(str, Array.from(activeStyles.values())));
|
|
488
|
+
currentIndex = marker[0];
|
|
489
|
+
}
|
|
490
|
+
if (marker[0] === marker[1].start) {
|
|
491
|
+
activeStyles.add(marker[1]);
|
|
492
|
+
}
|
|
493
|
+
if (marker[0] === marker[1].end) {
|
|
494
|
+
activeStyles.delete(marker[1]);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (currentIndex !== text.length) {
|
|
498
|
+
const str = text.slice(currentIndex, text.length);
|
|
499
|
+
blocks.push(renderBlockPart(str, Array.from(activeStyles.values())));
|
|
500
|
+
}
|
|
501
|
+
cleanTree(blocks.length);
|
|
502
|
+
return blocks;
|
|
503
|
+
};
|
|
504
|
+
return (_ctx, _cache) => {
|
|
505
|
+
return slot.value ? (openBlock(), createBlock(resolveDynamicComponent(slot.value), {
|
|
506
|
+
key: 0,
|
|
507
|
+
content,
|
|
508
|
+
props: blockProps,
|
|
509
|
+
block: _ctx.block
|
|
510
|
+
}, null, 8, ["block"])) : (openBlock(), createElementBlock("div", normalizeProps(mergeProps({ key: 1 }, blockProps)), [
|
|
511
|
+
(openBlock(), createBlock(resolveDynamicComponent(content)))
|
|
512
|
+
], 16));
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
const _sfc_main = /* @__PURE__ */ defineComponent({
|
|
517
|
+
__name: "TextEditor",
|
|
518
|
+
props: {
|
|
519
|
+
store: {},
|
|
520
|
+
placeholder: {},
|
|
521
|
+
decorator: { type: Function }
|
|
522
|
+
},
|
|
523
|
+
emits: ["keydown"],
|
|
524
|
+
setup(__props, { expose: __expose, emit: __emit }) {
|
|
525
|
+
const props = __props;
|
|
526
|
+
const emit = __emit;
|
|
527
|
+
const slots = useSlots();
|
|
528
|
+
const textEditorRef = ref();
|
|
529
|
+
const store = props.store ?? new TextEditorStore();
|
|
530
|
+
const onKeyDown = (e) => {
|
|
531
|
+
emit("keydown", e);
|
|
532
|
+
if (e.defaultPrevented)
|
|
533
|
+
return;
|
|
534
|
+
if (e.code === "Enter") {
|
|
535
|
+
e.preventDefault();
|
|
536
|
+
if (e.shiftKey) {
|
|
537
|
+
store.insertText("\n");
|
|
538
|
+
} else {
|
|
539
|
+
store.addNewLine();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (e.code === "Backspace") {
|
|
543
|
+
if (store.isCollapsed && store.currentBlock === store.blocks[0] && store.selection.anchor.offset === 0) {
|
|
544
|
+
e.preventDefault();
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
|
|
548
|
+
if (e.code === "KeyB" || e.code === "KeyI" || e.code === "KeyU") {
|
|
549
|
+
e.preventDefault();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
let cachedSelection = {};
|
|
554
|
+
useEventListener(document, "selectionchange", () => {
|
|
555
|
+
const sel = window.getSelection();
|
|
556
|
+
const anchor = findParent(sel.anchorNode, (el) => el.hasAttribute("data-block-id"));
|
|
557
|
+
if (anchor) {
|
|
558
|
+
const offset = anchor === sel.anchorNode ? 0 : calcOffsetToNode(anchor, sel.anchorNode) + sel.anchorOffset;
|
|
559
|
+
store.selection.anchor = { blockId: anchor.getAttribute("data-block-id"), offset };
|
|
560
|
+
}
|
|
561
|
+
const focus = findParent(sel.focusNode, (el) => el.hasAttribute("data-block-id"));
|
|
562
|
+
if (focus) {
|
|
563
|
+
const offset = anchor === sel.focusNode ? 0 : calcOffsetToNode(focus, sel.focusNode) + sel.focusOffset;
|
|
564
|
+
store.selection.focus = { blockId: focus.getAttribute("data-block-id"), offset };
|
|
565
|
+
}
|
|
566
|
+
cachedSelection = JSON.parse(JSON.stringify(store.selection));
|
|
567
|
+
});
|
|
568
|
+
let postRendered = false;
|
|
569
|
+
const onPostRender = () => {
|
|
570
|
+
if (postRendered)
|
|
571
|
+
return;
|
|
572
|
+
cachedSelection = {};
|
|
573
|
+
postRendered = true;
|
|
574
|
+
applySelection();
|
|
575
|
+
nextTick(() => {
|
|
576
|
+
postRendered = false;
|
|
577
|
+
});
|
|
578
|
+
};
|
|
579
|
+
const applySelection = () => {
|
|
580
|
+
if (isEqual(store.selection, cachedSelection))
|
|
581
|
+
return;
|
|
582
|
+
const nativeSelection = window.getSelection();
|
|
583
|
+
let anchor = null;
|
|
584
|
+
let focus = null;
|
|
585
|
+
for (let item of textEditorRef.value.children) {
|
|
586
|
+
if (item.getAttribute("data-block-id") === store.selection.anchor.blockId) {
|
|
587
|
+
anchor = item;
|
|
588
|
+
}
|
|
589
|
+
if (item.getAttribute("data-block-id") === store.selection.focus.blockId) {
|
|
590
|
+
focus = item;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (anchor && focus) {
|
|
594
|
+
nativeSelection.setBaseAndExtent(
|
|
595
|
+
...calcNodeByOffset(anchor, store.selection.anchor.offset),
|
|
596
|
+
...calcNodeByOffset(focus, store.selection.focus.offset)
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
cachedSelection = JSON.parse(JSON.stringify(store.selection));
|
|
600
|
+
};
|
|
601
|
+
watch(() => store.selection, applySelection, { deep: true, flush: "post" });
|
|
602
|
+
__expose({
|
|
603
|
+
store
|
|
604
|
+
});
|
|
605
|
+
return (_ctx, _cache) => {
|
|
606
|
+
return openBlock(), createElementBlock("div", {
|
|
607
|
+
ref_key: "textEditorRef",
|
|
608
|
+
ref: textEditorRef,
|
|
609
|
+
contenteditable: "",
|
|
610
|
+
onInput: _cache[0] || (_cache[0] = //@ts-ignore
|
|
611
|
+
(...args) => unref(store).onInput && unref(store).onInput(...args)),
|
|
612
|
+
onKeydown: onKeyDown
|
|
613
|
+
}, [
|
|
614
|
+
(openBlock(true), createElementBlock(Fragment, null, renderList(unref(store).blocks, (block) => {
|
|
615
|
+
return openBlock(), createBlock(_sfc_main$1, {
|
|
616
|
+
key: block.id,
|
|
617
|
+
block,
|
|
618
|
+
slots: unref(slots),
|
|
619
|
+
decorator: _ctx.decorator,
|
|
620
|
+
onPostrender: onPostRender
|
|
621
|
+
}, null, 8, ["block", "slots", "decorator"]);
|
|
622
|
+
}), 128)),
|
|
623
|
+
unref(store).blocks.length === 1 && unref(store).blocks[0].text === "" ? renderSlot(_ctx.$slots, "placeholder", { key: 0 }) : createCommentVNode("", true)
|
|
624
|
+
], 544);
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
export {
|
|
629
|
+
_sfc_main as TextEditor,
|
|
630
|
+
TextEditorStore
|
|
631
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vuewrite",
|
|
3
|
+
"description": "Rich Text Editor based on Vue3 reactivity",
|
|
4
|
+
"private": false,
|
|
5
|
+
"version": "0.0.1",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"author": "den59k",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/den59k/vuewrite.git"
|
|
11
|
+
},
|
|
12
|
+
"main": "dist/vuewrite.js",
|
|
13
|
+
"types": "dist/vuewrite.d.ts",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "vite",
|
|
16
|
+
"build": "vue-tsc && vite build",
|
|
17
|
+
"build:app": "vue-tsc && vite build -c vite-app.config.ts",
|
|
18
|
+
"preview": "vite preview"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"vue": "^3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@vitejs/plugin-vue": "^5.0.4",
|
|
25
|
+
"@vueuse/core": "^10.10.0",
|
|
26
|
+
"color2k": "^2.0.3",
|
|
27
|
+
"sass": "^1.77.2",
|
|
28
|
+
"typescript": "^5.2.2",
|
|
29
|
+
"vite": "^5.2.0",
|
|
30
|
+
"vite-plugin-dts": "^3.9.1",
|
|
31
|
+
"vue-tsc": "^2.0.6",
|
|
32
|
+
"vuesix": "^1.0.12"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
]
|
|
37
|
+
}
|