@anweb/nuxt-aneditor 0.1.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/README.md +81 -0
- package/dist/module.d.mts +14 -0
- package/dist/module.d.ts +14 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +29 -0
- package/dist/runtime/assets/icons/blockquote.svg +1 -0
- package/dist/runtime/assets/icons/bold.svg +1 -0
- package/dist/runtime/assets/icons/code-block.svg +1 -0
- package/dist/runtime/assets/icons/code.svg +1 -0
- package/dist/runtime/assets/icons/edit.svg +1 -0
- package/dist/runtime/assets/icons/expand.svg +1 -0
- package/dist/runtime/assets/icons/heading.svg +1 -0
- package/dist/runtime/assets/icons/horizontal-rule.svg +1 -0
- package/dist/runtime/assets/icons/image.svg +1 -0
- package/dist/runtime/assets/icons/italic.svg +1 -0
- package/dist/runtime/assets/icons/link.svg +1 -0
- package/dist/runtime/assets/icons/ordered-list.svg +1 -0
- package/dist/runtime/assets/icons/redo.svg +1 -0
- package/dist/runtime/assets/icons/remove.svg +1 -0
- package/dist/runtime/assets/icons/strikethrough.svg +1 -0
- package/dist/runtime/assets/icons/table.svg +1 -0
- package/dist/runtime/assets/icons/task-list.svg +1 -0
- package/dist/runtime/assets/icons/undo.svg +1 -0
- package/dist/runtime/assets/icons/unordered-list.svg +1 -0
- package/dist/runtime/assets/icons/upload.svg +1 -0
- package/dist/runtime/assets/icons/youtube.svg +1 -0
- package/dist/runtime/components/AnEditor/Editor.vue +630 -0
- package/dist/runtime/components/AnEditor/Editor.vue.d.ts +2 -0
- package/dist/runtime/components/AnEditor/Prompt.vue +50 -0
- package/dist/runtime/components/AnEditor/Prompt.vue.d.ts +2 -0
- package/dist/runtime/components/AnEditor/Toolbar.vue +191 -0
- package/dist/runtime/components/AnEditor/Toolbar.vue.d.ts +2 -0
- package/dist/runtime/components/AnEditor/Viewer.vue +16 -0
- package/dist/runtime/components/AnEditor/Viewer.vue.d.ts +2 -0
- package/dist/runtime/composables/useBlocks.d.ts +15 -0
- package/dist/runtime/composables/useBlocks.js +258 -0
- package/dist/runtime/composables/useHistory.d.ts +12 -0
- package/dist/runtime/composables/useHistory.js +56 -0
- package/dist/runtime/composables/useImage.d.ts +27 -0
- package/dist/runtime/composables/useImage.js +81 -0
- package/dist/runtime/composables/useList.d.ts +10 -0
- package/dist/runtime/composables/useList.js +116 -0
- package/dist/runtime/composables/useSelection.d.ts +20 -0
- package/dist/runtime/composables/useSelection.js +92 -0
- package/dist/runtime/composables/useTable.d.ts +29 -0
- package/dist/runtime/composables/useTable.js +175 -0
- package/dist/runtime/types/global.d.ts +8 -0
- package/dist/runtime/types/index.d.ts +1 -0
- package/dist/runtime/types/index.js +1 -0
- package/dist/runtime/utils/index.d.ts +3 -0
- package/dist/runtime/utils/index.js +3 -0
- package/dist/runtime/utils/parseMarkdown.d.ts +1 -0
- package/dist/runtime/utils/parseMarkdown.js +184 -0
- package/dist/runtime/utils/toMarkdown.d.ts +1 -0
- package/dist/runtime/utils/toMarkdown.js +233 -0
- package/dist/runtime/utils/youtube.d.ts +1 -0
- package/dist/runtime/utils/youtube.js +6 -0
- package/dist/types.d.mts +3 -0
- package/package.json +50 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { AnDropdown, Icon } from "#components";
|
|
3
|
+
const emit = defineEmits(["action"]);
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
activeFormats: { type: Set, required: false },
|
|
6
|
+
hasUpload: { type: Boolean, required: false }
|
|
7
|
+
});
|
|
8
|
+
const onAction = (type, event) => {
|
|
9
|
+
event?.preventDefault();
|
|
10
|
+
emit("action", type);
|
|
11
|
+
};
|
|
12
|
+
const isActive = (format) => {
|
|
13
|
+
return props.activeFormats?.has(format) ?? false;
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="an-editor-toolbar" role="toolbar">
|
|
19
|
+
<!-- Undo / Redo -->
|
|
20
|
+
<div class="an-editor-toolbar__group">
|
|
21
|
+
<button
|
|
22
|
+
class="an-editor-toolbar__btn"
|
|
23
|
+
title="Undo (Ctrl+Z)"
|
|
24
|
+
@mousedown.prevent="onAction('undo')"
|
|
25
|
+
>
|
|
26
|
+
<Icon name="aneditor:undo" />
|
|
27
|
+
</button>
|
|
28
|
+
<button
|
|
29
|
+
class="an-editor-toolbar__btn"
|
|
30
|
+
title="Redo (Ctrl+Shift+Z)"
|
|
31
|
+
@mousedown.prevent="onAction('redo')"
|
|
32
|
+
>
|
|
33
|
+
<Icon name="aneditor:redo" />
|
|
34
|
+
</button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<!-- Text formatting -->
|
|
38
|
+
<div class="an-editor-toolbar__group">
|
|
39
|
+
<button
|
|
40
|
+
class="an-editor-toolbar__btn"
|
|
41
|
+
:class="{ '-active': isActive('bold') }"
|
|
42
|
+
title="Bold (Ctrl+B)"
|
|
43
|
+
@mousedown.prevent="onAction('bold')"
|
|
44
|
+
>
|
|
45
|
+
<Icon name="aneditor:bold" />
|
|
46
|
+
</button>
|
|
47
|
+
<button
|
|
48
|
+
class="an-editor-toolbar__btn"
|
|
49
|
+
:class="{ '-active': isActive('italic') }"
|
|
50
|
+
title="Italic (Ctrl+I)"
|
|
51
|
+
@mousedown.prevent="onAction('italic')"
|
|
52
|
+
>
|
|
53
|
+
<Icon name="aneditor:italic" />
|
|
54
|
+
</button>
|
|
55
|
+
<button
|
|
56
|
+
class="an-editor-toolbar__btn"
|
|
57
|
+
:class="{ '-active': isActive('strikethrough') }"
|
|
58
|
+
title="Strikethrough"
|
|
59
|
+
@mousedown.prevent="onAction('strikethrough')"
|
|
60
|
+
>
|
|
61
|
+
<Icon name="aneditor:strikethrough" />
|
|
62
|
+
</button>
|
|
63
|
+
<AnDropdown area="bottom span-right">
|
|
64
|
+
<template #button="{ toggle }">
|
|
65
|
+
<button
|
|
66
|
+
class="an-editor-toolbar__btn"
|
|
67
|
+
:class="{ '-active': isActive('h1') || isActive('h2') || isActive('h3') || isActive('h4') || isActive('h5') || isActive('h6') }"
|
|
68
|
+
title="Heading"
|
|
69
|
+
@mousedown.prevent="toggle()"
|
|
70
|
+
>
|
|
71
|
+
<Icon name="aneditor:heading" />
|
|
72
|
+
</button>
|
|
73
|
+
</template>
|
|
74
|
+
<template #menu="{ close }">
|
|
75
|
+
<div class="an-editor-toolbar__submenu">
|
|
76
|
+
<button v-for="n in 6" :key="n" class="an-editor-toolbar__submenu-item" :class="{ '-active': isActive(`h${n}`) }" @mousedown.prevent="onAction(`h${n}`);
|
|
77
|
+
close()">
|
|
78
|
+
H{{ n }}
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
82
|
+
</AnDropdown>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<!-- Lists & structure -->
|
|
86
|
+
<div class="an-editor-toolbar__group">
|
|
87
|
+
<button
|
|
88
|
+
class="an-editor-toolbar__btn"
|
|
89
|
+
:class="{ '-active': isActive('unorderedList') }"
|
|
90
|
+
title="Bullet list"
|
|
91
|
+
@mousedown.prevent="onAction('unorderedList')"
|
|
92
|
+
>
|
|
93
|
+
<Icon name="aneditor:unordered-list" />
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
class="an-editor-toolbar__btn"
|
|
97
|
+
:class="{ '-active': isActive('orderedList') }"
|
|
98
|
+
title="Numbered list"
|
|
99
|
+
@mousedown.prevent="onAction('orderedList')"
|
|
100
|
+
>
|
|
101
|
+
<Icon name="aneditor:ordered-list" />
|
|
102
|
+
</button>
|
|
103
|
+
<button
|
|
104
|
+
class="an-editor-toolbar__btn"
|
|
105
|
+
:class="{ '-active': isActive('blockquote') }"
|
|
106
|
+
title="Blockquote"
|
|
107
|
+
@mousedown.prevent="onAction('blockquote')"
|
|
108
|
+
>
|
|
109
|
+
<Icon name="aneditor:blockquote" />
|
|
110
|
+
</button>
|
|
111
|
+
<button
|
|
112
|
+
class="an-editor-toolbar__btn"
|
|
113
|
+
title="Horizontal rule"
|
|
114
|
+
@mousedown.prevent="onAction('horizontalRule')"
|
|
115
|
+
>
|
|
116
|
+
<Icon name="aneditor:horizontal-rule" />
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
class="an-editor-toolbar__btn"
|
|
120
|
+
title="Insert table"
|
|
121
|
+
@click.prevent="onAction('table')"
|
|
122
|
+
>
|
|
123
|
+
<Icon name="aneditor:table" />
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<!-- Code -->
|
|
128
|
+
<div class="an-editor-toolbar__group">
|
|
129
|
+
<button
|
|
130
|
+
class="an-editor-toolbar__btn"
|
|
131
|
+
:class="{ '-active': isActive('code') }"
|
|
132
|
+
title="Inline code"
|
|
133
|
+
@mousedown.prevent="onAction('code')"
|
|
134
|
+
>
|
|
135
|
+
<Icon name="aneditor:code" />
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
class="an-editor-toolbar__btn"
|
|
139
|
+
title="Code block"
|
|
140
|
+
@mousedown.prevent="onAction('codeBlock')"
|
|
141
|
+
>
|
|
142
|
+
<Icon name="aneditor:code-block" />
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<!-- Media -->
|
|
147
|
+
<div class="an-editor-toolbar__group">
|
|
148
|
+
<button
|
|
149
|
+
class="an-editor-toolbar__btn"
|
|
150
|
+
title="Insert link (Ctrl+K)"
|
|
151
|
+
@click.prevent="onAction('link')"
|
|
152
|
+
>
|
|
153
|
+
<Icon name="aneditor:link" />
|
|
154
|
+
</button>
|
|
155
|
+
<AnDropdown area="bottom span-right">
|
|
156
|
+
<template #button="{ toggle }">
|
|
157
|
+
<button
|
|
158
|
+
class="an-editor-toolbar__btn"
|
|
159
|
+
title="Insert image"
|
|
160
|
+
@mousedown.prevent="toggle()"
|
|
161
|
+
>
|
|
162
|
+
<Icon name="aneditor:image" />
|
|
163
|
+
</button>
|
|
164
|
+
</template>
|
|
165
|
+
<template #menu="{ close }">
|
|
166
|
+
<div class="an-editor-toolbar__submenu">
|
|
167
|
+
<button class="an-editor-toolbar__submenu-item" @click.prevent="onAction('image');
|
|
168
|
+
close()">
|
|
169
|
+
By URL
|
|
170
|
+
</button>
|
|
171
|
+
<button v-if="hasUpload" class="an-editor-toolbar__submenu-item" @mousedown.prevent="onAction('imageUpload');
|
|
172
|
+
close()">
|
|
173
|
+
Upload
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</template>
|
|
177
|
+
</AnDropdown>
|
|
178
|
+
<button
|
|
179
|
+
class="an-editor-toolbar__btn"
|
|
180
|
+
title="YouTube video"
|
|
181
|
+
@click.prevent="onAction('youtube')"
|
|
182
|
+
>
|
|
183
|
+
<Icon name="aneditor:youtube" />
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</template>
|
|
188
|
+
|
|
189
|
+
<style scoped>
|
|
190
|
+
.an-editor-toolbar{background:var(--an-editor-toolbar-background,#fafafa);border-bottom:1px solid var(--an-editor-border,#e0e0e0);border-radius:8px 8px 0 0;flex-wrap:wrap;padding:8px 12px}.an-editor-toolbar,.an-editor-toolbar__group{align-items:center;display:flex;gap:2px}.an-editor-toolbar__group:not(:last-child):after{background:var(--an-editor-border,#e0e0e0);content:"";height:20px;margin:0 6px;width:1px}.an-editor-toolbar__btn{align-items:center;background:none;border:none;border-radius:6px;color:#555;cursor:pointer;display:flex;font-size:18px;height:32px;justify-content:center;transition:all .15s;width:32px}.an-editor-toolbar__btn:hover{background:rgba(0,0,0,.06);color:#1a1a2e}.an-editor-toolbar__btn.-active{background:rgba(124,131,255,.15);color:#7c83ff}.an-editor-toolbar__submenu{background:#fff;border:1px solid #e8eaed;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.1);min-width:120px;padding:4px}.an-editor-toolbar__submenu-item{background:none;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;display:block;font-size:13px;padding:8px 12px;text-align:left;transition:background .1s;width:100%}.an-editor-toolbar__submenu-item:hover{background:#f4f5f7}.an-editor-toolbar__submenu-item.-active{color:#7c83ff;font-weight:600}
|
|
191
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { parseMarkdown } from "#imports";
|
|
4
|
+
const props = defineProps({
|
|
5
|
+
content: { type: String, required: true }
|
|
6
|
+
});
|
|
7
|
+
const html = computed(() => parseMarkdown(props.content));
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div class="an-editor-viewer" v-html="html" />
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<style scoped>
|
|
15
|
+
.an-editor-viewer{font-size:var(--an-editor-font-size,15px);line-height:1.6}.an-editor-viewer :deep(h1){font-size:2em;font-weight:700;margin:.5em 0}.an-editor-viewer :deep(h2){font-size:1.5em;font-weight:700;margin:.5em 0}.an-editor-viewer :deep(h3){font-size:1.17em;font-weight:700;margin:.5em 0}.an-editor-viewer :deep(h4){font-size:1em;font-weight:700;margin:.5em 0}.an-editor-viewer :deep(h5){font-size:.83em;font-weight:700;margin:.5em 0}.an-editor-viewer :deep(h6){font-size:.67em;font-weight:700;margin:.5em 0}.an-editor-viewer :deep(strong){font-weight:700}.an-editor-viewer :deep(em){font-style:italic}.an-editor-viewer :deep(s){text-decoration:line-through}.an-editor-viewer :deep(a){color:#7c83ff;text-decoration:underline}.an-editor-viewer :deep(blockquote){border-left:3px solid #e0e0e0;color:#666;margin:12px 0;padding-left:16px}.an-editor-viewer :deep(ol),.an-editor-viewer :deep(ul){margin:8px 0;padding-left:24px}.an-editor-viewer :deep(code){background:#f4f4f5;border-radius:4px;font-family:SF Mono,Fira Code,monospace;font-size:.9em;padding:2px 6px}.an-editor-viewer :deep(pre){background:#1e1e2e;border-radius:8px;color:#cdd6f4;margin:12px 0;min-height:3em;overflow-x:auto;padding:16px}.an-editor-viewer :deep(pre) code{background:none;border-radius:0;color:inherit;display:block;min-height:1.4em;padding:0}.an-editor-viewer :deep(hr){border:none;border-top:2px solid #e0e0e0;margin:16px 0}.an-editor-viewer :deep(.an-editor__youtube){margin:12px 0}.an-editor-viewer :deep(.an-editor__youtube) iframe{aspect-ratio:16/9;border:none;border-radius:8px;width:100%}.an-editor-viewer :deep(.an-editor__image){margin:12px 0}.an-editor-viewer :deep(.an-editor__image) img{border-radius:8px;display:block;max-width:100%}.an-editor-viewer :deep(.an-editor__table){border-collapse:collapse;font-size:.95em;margin:12px 0;width:100%}.an-editor-viewer :deep(.an-editor__table) td,.an-editor-viewer :deep(.an-editor__table) th{border:1px solid #d0d5dd;box-sizing:border-box;line-height:1.5;min-width:60px;padding:8px 12px;text-align:left}.an-editor-viewer :deep(.an-editor__table) th{background:#f4f5f7;font-weight:600}.an-editor-viewer :deep(.an-editor__table) tr:hover td{background:#f9fafb}.an-editor-viewer :deep(p){margin:8px 0}.an-editor-viewer :deep(p):first-child{margin-top:0}.an-editor-viewer :deep(p):last-child{margin-bottom:0}
|
|
16
|
+
</style>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { useSelection } from './useSelection.js';
|
|
3
|
+
type TSelectionUtils = ReturnType<typeof useSelection>;
|
|
4
|
+
export declare const useBlocks: (refContent: Ref<HTMLDivElement | null>, sel: TSelectionUtils, syncToModel: () => void) => {
|
|
5
|
+
BLOCK_TAGS: Set<string>;
|
|
6
|
+
getCurrentBlock: () => HTMLElement | null;
|
|
7
|
+
isInsideCodeBlock: () => boolean;
|
|
8
|
+
ensureBlockWrapped: () => HTMLElement | null;
|
|
9
|
+
replaceBlock: (tagName: string) => void;
|
|
10
|
+
insertBlockAtCursor: (element: HTMLElement) => void;
|
|
11
|
+
wrapBlockInTag: (tagName: string) => void;
|
|
12
|
+
wrapSelection: (tagName: string, className?: string) => void;
|
|
13
|
+
normalizeContent: () => void;
|
|
14
|
+
};
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
const BLOCK_TAGS = /* @__PURE__ */ new Set([
|
|
2
|
+
"p",
|
|
3
|
+
"h1",
|
|
4
|
+
"h2",
|
|
5
|
+
"h3",
|
|
6
|
+
"h4",
|
|
7
|
+
"h5",
|
|
8
|
+
"h6",
|
|
9
|
+
"ul",
|
|
10
|
+
"ol",
|
|
11
|
+
"li",
|
|
12
|
+
"blockquote",
|
|
13
|
+
"pre",
|
|
14
|
+
"hr",
|
|
15
|
+
"table",
|
|
16
|
+
"figure",
|
|
17
|
+
"div"
|
|
18
|
+
]);
|
|
19
|
+
export const useBlocks = (refContent, sel, syncToModel) => {
|
|
20
|
+
const getCurrentBlock = () => {
|
|
21
|
+
const s = sel.getSelection();
|
|
22
|
+
if (!s) return null;
|
|
23
|
+
let node = s.range.commonAncestorContainer;
|
|
24
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
|
|
25
|
+
while (node && node !== refContent.value && node.parentNode !== refContent.value) {
|
|
26
|
+
node = node.parentNode;
|
|
27
|
+
}
|
|
28
|
+
return node && node !== refContent.value ? node : null;
|
|
29
|
+
};
|
|
30
|
+
const isInsideCodeBlock = () => {
|
|
31
|
+
const s = sel.getSelection();
|
|
32
|
+
if (!s) return false;
|
|
33
|
+
let node = s.range.commonAncestorContainer;
|
|
34
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
|
|
35
|
+
while (node && node !== refContent.value) {
|
|
36
|
+
if (node instanceof HTMLElement && node.tagName.toLowerCase() === "pre") return true;
|
|
37
|
+
node = node.parentNode;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
};
|
|
41
|
+
const ensureBlockWrapped = () => {
|
|
42
|
+
const block = getCurrentBlock();
|
|
43
|
+
if (block && BLOCK_TAGS.has(block.tagName.toLowerCase())) return block;
|
|
44
|
+
const s = sel.getSelection();
|
|
45
|
+
if (!s) return null;
|
|
46
|
+
let directChild = s.range.commonAncestorContainer;
|
|
47
|
+
if (directChild.nodeType === Node.TEXT_NODE) directChild = directChild.parentNode;
|
|
48
|
+
while (directChild && directChild.parentNode !== refContent.value) {
|
|
49
|
+
directChild = directChild.parentNode;
|
|
50
|
+
}
|
|
51
|
+
if (!directChild || directChild === refContent.value) {
|
|
52
|
+
if (!refContent.value) return null;
|
|
53
|
+
const p2 = document.createElement("p");
|
|
54
|
+
p2.innerHTML = "<br>";
|
|
55
|
+
refContent.value.appendChild(p2);
|
|
56
|
+
return p2;
|
|
57
|
+
}
|
|
58
|
+
const p = document.createElement("p");
|
|
59
|
+
refContent.value.insertBefore(p, directChild);
|
|
60
|
+
p.appendChild(directChild);
|
|
61
|
+
return p;
|
|
62
|
+
};
|
|
63
|
+
const replaceBlock = (tagName) => {
|
|
64
|
+
if (isInsideCodeBlock()) return;
|
|
65
|
+
const block = ensureBlockWrapped();
|
|
66
|
+
if (!block) return;
|
|
67
|
+
const offset = sel.getCaretOffset(block);
|
|
68
|
+
if (block.tagName.toLowerCase() === tagName.toLowerCase()) {
|
|
69
|
+
const p = document.createElement("p");
|
|
70
|
+
p.innerHTML = block.innerHTML;
|
|
71
|
+
block.replaceWith(p);
|
|
72
|
+
sel.restoreCaretOffset(p, offset);
|
|
73
|
+
syncToModel();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const newEl = document.createElement(tagName);
|
|
77
|
+
newEl.innerHTML = block.innerHTML;
|
|
78
|
+
block.replaceWith(newEl);
|
|
79
|
+
sel.restoreCaretOffset(newEl, offset);
|
|
80
|
+
syncToModel();
|
|
81
|
+
};
|
|
82
|
+
const insertBlockAtCursor = (element) => {
|
|
83
|
+
const s = sel.getSelection();
|
|
84
|
+
if (!s) {
|
|
85
|
+
refContent.value?.appendChild(element);
|
|
86
|
+
} else {
|
|
87
|
+
const block = getCurrentBlock();
|
|
88
|
+
if (block) {
|
|
89
|
+
block.parentNode.insertBefore(element, block.nextSibling);
|
|
90
|
+
} else {
|
|
91
|
+
refContent.value.appendChild(element);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const p = document.createElement("p");
|
|
95
|
+
p.innerHTML = "<br>";
|
|
96
|
+
element.parentNode.insertBefore(p, element.nextSibling);
|
|
97
|
+
const range = document.createRange();
|
|
98
|
+
range.setStart(p, 0);
|
|
99
|
+
range.collapse(true);
|
|
100
|
+
const selection = window.getSelection();
|
|
101
|
+
selection?.removeAllRanges();
|
|
102
|
+
selection?.addRange(range);
|
|
103
|
+
syncToModel();
|
|
104
|
+
};
|
|
105
|
+
const wrapBlockInTag = (tagName) => {
|
|
106
|
+
if (isInsideCodeBlock()) return;
|
|
107
|
+
const block = ensureBlockWrapped();
|
|
108
|
+
if (!block) return;
|
|
109
|
+
const offset = sel.getCaretOffset(block);
|
|
110
|
+
if (block.tagName.toLowerCase() === tagName.toLowerCase()) {
|
|
111
|
+
const p = document.createElement("p");
|
|
112
|
+
p.innerHTML = block.innerHTML;
|
|
113
|
+
block.replaceWith(p);
|
|
114
|
+
sel.restoreCaretOffset(p, offset);
|
|
115
|
+
syncToModel();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const wrapper = document.createElement(tagName);
|
|
119
|
+
wrapper.innerHTML = `<p>${block.innerHTML}</p>`;
|
|
120
|
+
block.replaceWith(wrapper);
|
|
121
|
+
sel.restoreCaretOffset(wrapper, offset);
|
|
122
|
+
syncToModel();
|
|
123
|
+
};
|
|
124
|
+
const wrapSelection = (tagName, className) => {
|
|
125
|
+
const s = sel.getSelection();
|
|
126
|
+
if (!s) return;
|
|
127
|
+
if (isInsideCodeBlock()) return;
|
|
128
|
+
const { selection, range } = s;
|
|
129
|
+
let node = range.commonAncestorContainer;
|
|
130
|
+
if (node.nodeType === Node.TEXT_NODE) node = node.parentNode;
|
|
131
|
+
while (node && node !== refContent.value) {
|
|
132
|
+
if (node instanceof HTMLElement && node.tagName.toLowerCase() === tagName.toLowerCase()) {
|
|
133
|
+
const wrapper = node;
|
|
134
|
+
const parent = wrapper.parentNode;
|
|
135
|
+
const wrapperRange = document.createRange();
|
|
136
|
+
wrapperRange.selectNodeContents(wrapper);
|
|
137
|
+
const coversAll = range.compareBoundaryPoints(Range.START_TO_START, wrapperRange) <= 0 && range.compareBoundaryPoints(Range.END_TO_END, wrapperRange) >= 0;
|
|
138
|
+
if (coversAll) {
|
|
139
|
+
const offsets = sel.getSelectionOffsets(parent);
|
|
140
|
+
while (wrapper.firstChild) {
|
|
141
|
+
parent.insertBefore(wrapper.firstChild, wrapper);
|
|
142
|
+
}
|
|
143
|
+
parent.removeChild(wrapper);
|
|
144
|
+
parent.normalize();
|
|
145
|
+
sel.restoreSelectionOffsets(parent, offsets.start, offsets.end);
|
|
146
|
+
} else {
|
|
147
|
+
const beforeRange = document.createRange();
|
|
148
|
+
beforeRange.setStart(wrapperRange.startContainer, wrapperRange.startOffset);
|
|
149
|
+
beforeRange.setEnd(range.startContainer, range.startOffset);
|
|
150
|
+
const afterRange = document.createRange();
|
|
151
|
+
afterRange.setStart(range.endContainer, range.endOffset);
|
|
152
|
+
afterRange.setEnd(wrapperRange.endContainer, wrapperRange.endOffset);
|
|
153
|
+
const beforeContent = beforeRange.cloneContents();
|
|
154
|
+
const selectedContent = range.cloneContents();
|
|
155
|
+
const afterContent = afterRange.cloneContents();
|
|
156
|
+
const frag = document.createDocumentFragment();
|
|
157
|
+
if (beforeContent.textContent) {
|
|
158
|
+
const beforeEl = document.createElement(tagName);
|
|
159
|
+
if (className) beforeEl.className = className;
|
|
160
|
+
beforeEl.appendChild(beforeContent);
|
|
161
|
+
frag.appendChild(beforeEl);
|
|
162
|
+
}
|
|
163
|
+
frag.appendChild(selectedContent);
|
|
164
|
+
if (afterContent.textContent) {
|
|
165
|
+
const afterEl = document.createElement(tagName);
|
|
166
|
+
if (className) afterEl.className = className;
|
|
167
|
+
afterEl.appendChild(afterContent);
|
|
168
|
+
frag.appendChild(afterEl);
|
|
169
|
+
}
|
|
170
|
+
const parentOffsets = sel.getSelectionOffsets(parent);
|
|
171
|
+
parent.replaceChild(frag, wrapper);
|
|
172
|
+
parent.normalize();
|
|
173
|
+
sel.restoreSelectionOffsets(parent, parentOffsets.start, parentOffsets.end);
|
|
174
|
+
}
|
|
175
|
+
syncToModel();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
node = node.parentNode;
|
|
179
|
+
}
|
|
180
|
+
if (range.collapsed) return;
|
|
181
|
+
try {
|
|
182
|
+
const wrapper = document.createElement(tagName);
|
|
183
|
+
if (className) wrapper.className = className;
|
|
184
|
+
range.surroundContents(wrapper);
|
|
185
|
+
const newRange = document.createRange();
|
|
186
|
+
newRange.selectNodeContents(wrapper);
|
|
187
|
+
selection.removeAllRanges();
|
|
188
|
+
selection.addRange(newRange);
|
|
189
|
+
} catch {
|
|
190
|
+
const fragment = range.extractContents();
|
|
191
|
+
const wrapper = document.createElement(tagName);
|
|
192
|
+
if (className) wrapper.className = className;
|
|
193
|
+
wrapper.appendChild(fragment);
|
|
194
|
+
range.insertNode(wrapper);
|
|
195
|
+
const newRange = document.createRange();
|
|
196
|
+
newRange.selectNodeContents(wrapper);
|
|
197
|
+
selection.removeAllRanges();
|
|
198
|
+
selection.addRange(newRange);
|
|
199
|
+
}
|
|
200
|
+
syncToModel();
|
|
201
|
+
};
|
|
202
|
+
const normalizeContent = () => {
|
|
203
|
+
if (!refContent.value) return;
|
|
204
|
+
let caretSaved = false;
|
|
205
|
+
let caretOffset = 0;
|
|
206
|
+
const saveCaret = () => {
|
|
207
|
+
if (!caretSaved) {
|
|
208
|
+
caretOffset = sel.getCaretOffsetInContent();
|
|
209
|
+
caretSaved = true;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
for (const node of Array.from(refContent.value.childNodes)) {
|
|
213
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
214
|
+
const el = node;
|
|
215
|
+
if (el.tagName.toLowerCase() === "div" && !el.classList.length) {
|
|
216
|
+
saveCaret();
|
|
217
|
+
const p = document.createElement("p");
|
|
218
|
+
p.innerHTML = el.innerHTML;
|
|
219
|
+
el.replaceWith(p);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const children = Array.from(refContent.value.childNodes);
|
|
224
|
+
let i = 0;
|
|
225
|
+
while (i < children.length) {
|
|
226
|
+
const node = children[i];
|
|
227
|
+
const isBareInline = node.nodeType === Node.TEXT_NODE ? !!node.textContent?.trim() : node.nodeType === Node.ELEMENT_NODE && !BLOCK_TAGS.has(node.tagName.toLowerCase());
|
|
228
|
+
if (isBareInline) {
|
|
229
|
+
saveCaret();
|
|
230
|
+
const p = document.createElement("p");
|
|
231
|
+
refContent.value.insertBefore(p, node);
|
|
232
|
+
while (i < children.length) {
|
|
233
|
+
const n = children[i];
|
|
234
|
+
const isInline = n.nodeType === Node.TEXT_NODE || n.nodeType === Node.ELEMENT_NODE && !BLOCK_TAGS.has(n.tagName.toLowerCase());
|
|
235
|
+
if (!isInline) break;
|
|
236
|
+
p.appendChild(n);
|
|
237
|
+
i++;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
i++;
|
|
242
|
+
}
|
|
243
|
+
if (caretSaved) {
|
|
244
|
+
sel.restoreCaretOffset(refContent.value, caretOffset);
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
return {
|
|
248
|
+
BLOCK_TAGS,
|
|
249
|
+
getCurrentBlock,
|
|
250
|
+
isInsideCodeBlock,
|
|
251
|
+
ensureBlockWrapped,
|
|
252
|
+
replaceBlock,
|
|
253
|
+
insertBlockAtCursor,
|
|
254
|
+
wrapBlockInTag,
|
|
255
|
+
wrapSelection,
|
|
256
|
+
normalizeContent
|
|
257
|
+
};
|
|
258
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { useSelection } from './useSelection.js';
|
|
3
|
+
type TSelectionUtils = ReturnType<typeof useSelection>;
|
|
4
|
+
export declare const useHistory: (refContent: Ref<HTMLDivElement | null>, selection: TSelectionUtils, options?: {
|
|
5
|
+
limit?: number;
|
|
6
|
+
}) => {
|
|
7
|
+
pushHistory: () => void;
|
|
8
|
+
undo: (emit: (md: string) => void, toMarkdown: (html: string) => string) => void;
|
|
9
|
+
redo: (emit: (md: string) => void, toMarkdown: (html: string) => string) => void;
|
|
10
|
+
readonly isUndoRedo: boolean;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const useHistory = (refContent, selection, options = {}) => {
|
|
2
|
+
const HISTORY_LIMIT = options.limit ?? 50;
|
|
3
|
+
const history = [];
|
|
4
|
+
let historyIndex = -1;
|
|
5
|
+
let isUndoRedo = false;
|
|
6
|
+
const pushHistory = () => {
|
|
7
|
+
if (!refContent.value) return;
|
|
8
|
+
const html = refContent.value.innerHTML;
|
|
9
|
+
const caretOffset = selection.getCaretOffsetInContent();
|
|
10
|
+
if (historyIndex >= 0 && history[historyIndex].html === html) return;
|
|
11
|
+
history.splice(historyIndex + 1);
|
|
12
|
+
history.push({ html, caretOffset });
|
|
13
|
+
if (history.length > HISTORY_LIMIT) {
|
|
14
|
+
history.shift();
|
|
15
|
+
}
|
|
16
|
+
historyIndex = history.length - 1;
|
|
17
|
+
};
|
|
18
|
+
const applyHistory = (emit, toMarkdown) => {
|
|
19
|
+
const entry = history[historyIndex];
|
|
20
|
+
if (!entry || !refContent.value) return;
|
|
21
|
+
isUndoRedo = true;
|
|
22
|
+
refContent.value.innerHTML = entry.html;
|
|
23
|
+
selection.restoreCaretOffset(refContent.value, entry.caretOffset);
|
|
24
|
+
const html = refContent.value.innerHTML;
|
|
25
|
+
const md = toMarkdown(html);
|
|
26
|
+
emit(md);
|
|
27
|
+
isUndoRedo = false;
|
|
28
|
+
};
|
|
29
|
+
const undo = (emit, toMarkdown) => {
|
|
30
|
+
if (historyIndex <= 0 || !refContent.value) return;
|
|
31
|
+
if (historyIndex === history.length - 1) {
|
|
32
|
+
const html = refContent.value.innerHTML;
|
|
33
|
+
const caretOffset = selection.getCaretOffsetInContent();
|
|
34
|
+
if (history[historyIndex].html !== html) {
|
|
35
|
+
history.splice(historyIndex + 1);
|
|
36
|
+
history.push({ html, caretOffset });
|
|
37
|
+
historyIndex = history.length - 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
historyIndex--;
|
|
41
|
+
applyHistory(emit, toMarkdown);
|
|
42
|
+
};
|
|
43
|
+
const redo = (emit, toMarkdown) => {
|
|
44
|
+
if (historyIndex >= history.length - 1 || !refContent.value) return;
|
|
45
|
+
historyIndex++;
|
|
46
|
+
applyHistory(emit, toMarkdown);
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
pushHistory,
|
|
50
|
+
undo,
|
|
51
|
+
redo,
|
|
52
|
+
get isUndoRedo() {
|
|
53
|
+
return isUndoRedo;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type Ref } from 'vue';
|
|
2
|
+
export declare const useImage: (refContent: Ref<HTMLDivElement | null>, syncToModel: () => void) => {
|
|
3
|
+
activeImage: Ref<{
|
|
4
|
+
img: HTMLImageElement;
|
|
5
|
+
figure: HTMLElement;
|
|
6
|
+
} | null, {
|
|
7
|
+
img: HTMLImageElement;
|
|
8
|
+
figure: HTMLElement;
|
|
9
|
+
} | {
|
|
10
|
+
img: HTMLImageElement;
|
|
11
|
+
figure: HTMLElement;
|
|
12
|
+
} | null>;
|
|
13
|
+
createImageElement: (url: string, alt?: string) => HTMLElement;
|
|
14
|
+
imageOverlayStyle: import("vue").ComputedRef<{
|
|
15
|
+
top?: undefined;
|
|
16
|
+
left?: undefined;
|
|
17
|
+
width?: undefined;
|
|
18
|
+
height?: undefined;
|
|
19
|
+
} | {
|
|
20
|
+
top: string;
|
|
21
|
+
left: string;
|
|
22
|
+
width: string;
|
|
23
|
+
height: string;
|
|
24
|
+
}>;
|
|
25
|
+
onImageResizeStart: (e: MouseEvent) => void;
|
|
26
|
+
imageExpandFull: () => void;
|
|
27
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ref, computed, nextTick, onScopeDispose } from "vue";
|
|
2
|
+
import { useThrottleFn } from "@vueuse/core";
|
|
3
|
+
export const useImage = (refContent, syncToModel) => {
|
|
4
|
+
const activeImage = ref(null);
|
|
5
|
+
const imageResizing = ref(null);
|
|
6
|
+
const createImageElement = (url, alt = "") => {
|
|
7
|
+
const figure = document.createElement("figure");
|
|
8
|
+
figure.className = "an-editor__image";
|
|
9
|
+
const img = document.createElement("img");
|
|
10
|
+
img.src = url;
|
|
11
|
+
img.alt = alt;
|
|
12
|
+
figure.appendChild(img);
|
|
13
|
+
return figure;
|
|
14
|
+
};
|
|
15
|
+
const imageOverlayStyle = computed(() => {
|
|
16
|
+
if (!activeImage.value) return {};
|
|
17
|
+
const editorRect = refContent.value?.getBoundingClientRect();
|
|
18
|
+
if (!editorRect) return {};
|
|
19
|
+
const rect = activeImage.value.img.getBoundingClientRect();
|
|
20
|
+
return {
|
|
21
|
+
top: `${rect.top - editorRect.top}px`,
|
|
22
|
+
left: `${rect.left - editorRect.left}px`,
|
|
23
|
+
width: `${rect.width}px`,
|
|
24
|
+
height: `${rect.height}px`
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
const onImageResizeMove = useThrottleFn((e) => {
|
|
28
|
+
if (!imageResizing.value) return;
|
|
29
|
+
const { startX, startWidth, img } = imageResizing.value;
|
|
30
|
+
const delta = e.clientX - startX;
|
|
31
|
+
const newWidth = Math.max(80, startWidth + delta);
|
|
32
|
+
const maxWidth = refContent.value?.clientWidth ?? 800;
|
|
33
|
+
img.style.width = `${Math.min(newWidth, maxWidth)}px`;
|
|
34
|
+
activeImage.value = { ...activeImage.value };
|
|
35
|
+
}, 32);
|
|
36
|
+
const handleResizeMove = (e) => onImageResizeMove(e);
|
|
37
|
+
const handleResizeEnd = () => {
|
|
38
|
+
if (!imageResizing.value) return;
|
|
39
|
+
document.body.style.cursor = "";
|
|
40
|
+
document.body.style.userSelect = "";
|
|
41
|
+
imageResizing.value = null;
|
|
42
|
+
document.removeEventListener("mousemove", handleResizeMove);
|
|
43
|
+
document.removeEventListener("mouseup", handleResizeEnd);
|
|
44
|
+
syncToModel();
|
|
45
|
+
};
|
|
46
|
+
const onImageResizeStart = (e) => {
|
|
47
|
+
if (!activeImage.value) return;
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
e.stopPropagation();
|
|
50
|
+
const { img } = activeImage.value;
|
|
51
|
+
document.body.style.cursor = "nwse-resize";
|
|
52
|
+
document.body.style.userSelect = "none";
|
|
53
|
+
imageResizing.value = {
|
|
54
|
+
startX: e.clientX,
|
|
55
|
+
startWidth: img.offsetWidth,
|
|
56
|
+
img
|
|
57
|
+
};
|
|
58
|
+
document.addEventListener("mousemove", handleResizeMove);
|
|
59
|
+
document.addEventListener("mouseup", handleResizeEnd);
|
|
60
|
+
};
|
|
61
|
+
const imageExpandFull = () => {
|
|
62
|
+
if (!activeImage.value) return;
|
|
63
|
+
const { img } = activeImage.value;
|
|
64
|
+
img.style.width = "100%";
|
|
65
|
+
nextTick(() => {
|
|
66
|
+
activeImage.value = { ...activeImage.value };
|
|
67
|
+
});
|
|
68
|
+
syncToModel();
|
|
69
|
+
};
|
|
70
|
+
onScopeDispose(() => {
|
|
71
|
+
document.removeEventListener("mousemove", handleResizeMove);
|
|
72
|
+
document.removeEventListener("mouseup", handleResizeEnd);
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
activeImage,
|
|
76
|
+
createImageElement,
|
|
77
|
+
imageOverlayStyle,
|
|
78
|
+
onImageResizeStart,
|
|
79
|
+
imageExpandFull
|
|
80
|
+
};
|
|
81
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { useSelection } from './useSelection.js';
|
|
3
|
+
type TSelectionUtils = ReturnType<typeof useSelection>;
|
|
4
|
+
export declare const useList: (refContent: Ref<HTMLDivElement | null>, sel: TSelectionUtils, syncToModel: () => void, ensureBlockWrapped: () => HTMLElement | null, isInsideCodeBlock: () => boolean) => {
|
|
5
|
+
findParentLi: () => HTMLElement | null;
|
|
6
|
+
indentListItem: (li: HTMLElement) => void;
|
|
7
|
+
outdentListItem: (li: HTMLElement) => void;
|
|
8
|
+
convertToList: (tagName: "ul" | "ol") => void;
|
|
9
|
+
};
|
|
10
|
+
export {};
|