@adminforth/rich-editor 1.1.0 โ 1.2.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/.woodpecker/release.yml +1 -2
- package/README.md +7 -0
- package/build.log +12 -2
- package/dist/custom/async-queue.ts +31 -0
- package/dist/custom/package-lock.json +74 -0
- package/dist/custom/package.json +15 -0
- package/dist/custom/quillEditor.vue +562 -0
- package/dist/custom/tsconfig.json +19 -0
- package/package.json +3 -2
package/.woodpecker/release.yml
CHANGED
|
@@ -22,9 +22,8 @@ steps:
|
|
|
22
22
|
image: node:20
|
|
23
23
|
when:
|
|
24
24
|
- event: push
|
|
25
|
-
volumes:
|
|
26
|
-
- /var/run/docker.sock:/var/run/docker.sock
|
|
27
25
|
commands:
|
|
26
|
+
- apt update && apt install -y rsync
|
|
28
27
|
- export $(cat /woodpecker/deploy.vault.env | xargs)
|
|
29
28
|
- npm clean-install
|
|
30
29
|
- /bin/bash ./.woodpecker/buildRelease.sh
|
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# AdminForth RichEditor Plugin
|
|
2
|
+
|
|
3
|
+
<img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /> <img src="https://woodpecker.devforth.io/api/badges/3848/status.svg" alt="Build Status" /> <a href="https://www.npmjs.com/package/@adminforth/rich-editor"> <img src="https://img.shields.io/npm/dt/@adminforth/rich-editor" alt="npm downloads" /></a> <a href="https://www.npmjs.com/package/@adminforth/rich-editor"><img src="https://img.shields.io/npm/v/@adminforth/rich-editor" alt="npm version" /></a> <a href="https://www.npmjs.com/package/@adminforth/rich-editor">
|
|
4
|
+
|
|
5
|
+
Allows to add a rich text editor to your AdminForth text columns.
|
|
6
|
+
|
|
7
|
+
## For ussage, see [AdminForth RichEditor Documentation](https://adminforth.dev/docs/tutorial/Plugins/RichEditor/)
|
package/build.log
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
|
|
2
|
-
> @adminforth/rich-editor@1.0.
|
|
3
|
-
> tsc
|
|
2
|
+
> @adminforth/rich-editor@1.0.0 build
|
|
3
|
+
> tsc && rsync -av --exclude 'node_modules' custom dist/
|
|
4
4
|
|
|
5
|
+
sending incremental file list
|
|
6
|
+
custom/
|
|
7
|
+
custom/async-queue.ts
|
|
8
|
+
custom/package-lock.json
|
|
9
|
+
custom/package.json
|
|
10
|
+
custom/quillEditor.vue
|
|
11
|
+
custom/tsconfig.json
|
|
12
|
+
|
|
13
|
+
sent 19,606 bytes received 115 bytes 39,442.00 bytes/sec
|
|
14
|
+
total size is 19,183 speedup is 0.97
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
export default class AsyncQueue {
|
|
4
|
+
queue: (() => Promise<any>)[];
|
|
5
|
+
processing: boolean;
|
|
6
|
+
|
|
7
|
+
constructor() {
|
|
8
|
+
this.queue = [];
|
|
9
|
+
this.processing = false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async add(task: () => Promise<any>) {
|
|
13
|
+
this.queue.push(task);
|
|
14
|
+
if (!this.processing) {
|
|
15
|
+
this.process();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async process() {
|
|
20
|
+
this.processing = true;
|
|
21
|
+
while (this.queue.length > 0) {
|
|
22
|
+
const task = this.queue.shift()!;
|
|
23
|
+
try {
|
|
24
|
+
await task();
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('Task encountered an error:', error);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
this.processing = false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "custom",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"lockfileVersion": 3,
|
|
5
|
+
"requires": true,
|
|
6
|
+
"packages": {
|
|
7
|
+
"": {
|
|
8
|
+
"name": "custom",
|
|
9
|
+
"version": "1.0.0",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"quill": "^2.0.2"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"node_modules/eventemitter3": {
|
|
16
|
+
"version": "5.0.1",
|
|
17
|
+
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
|
18
|
+
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="
|
|
19
|
+
},
|
|
20
|
+
"node_modules/fast-diff": {
|
|
21
|
+
"version": "1.3.0",
|
|
22
|
+
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
|
23
|
+
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="
|
|
24
|
+
},
|
|
25
|
+
"node_modules/lodash-es": {
|
|
26
|
+
"version": "4.17.21",
|
|
27
|
+
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
|
28
|
+
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="
|
|
29
|
+
},
|
|
30
|
+
"node_modules/lodash.clonedeep": {
|
|
31
|
+
"version": "4.5.0",
|
|
32
|
+
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
|
33
|
+
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ=="
|
|
34
|
+
},
|
|
35
|
+
"node_modules/lodash.isequal": {
|
|
36
|
+
"version": "4.5.0",
|
|
37
|
+
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
|
38
|
+
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="
|
|
39
|
+
},
|
|
40
|
+
"node_modules/parchment": {
|
|
41
|
+
"version": "3.0.0",
|
|
42
|
+
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
|
43
|
+
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A=="
|
|
44
|
+
},
|
|
45
|
+
"node_modules/quill": {
|
|
46
|
+
"version": "2.0.2",
|
|
47
|
+
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.2.tgz",
|
|
48
|
+
"integrity": "sha512-QfazNrhMakEdRG57IoYFwffUIr04LWJxbS/ZkidRFXYCQt63c1gK6Z7IHUXMx/Vh25WgPBU42oBaNzQ0K1R/xw==",
|
|
49
|
+
"license": "BSD-3-Clause",
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"eventemitter3": "^5.0.1",
|
|
52
|
+
"lodash-es": "^4.17.21",
|
|
53
|
+
"parchment": "^3.0.0",
|
|
54
|
+
"quill-delta": "^5.1.0"
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"npm": ">=8.2.3"
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"node_modules/quill-delta": {
|
|
61
|
+
"version": "5.1.0",
|
|
62
|
+
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
|
63
|
+
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"fast-diff": "^1.3.0",
|
|
66
|
+
"lodash.clonedeep": "^4.5.0",
|
|
67
|
+
"lodash.isequal": "^4.5.0"
|
|
68
|
+
},
|
|
69
|
+
"engines": {
|
|
70
|
+
"node": ">= 12.0.0"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "custom",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [],
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "ISC",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"quill": "^2.0.2"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500
|
|
4
|
+
focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400
|
|
5
|
+
dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500 whitespace-normal af-quill-editor"
|
|
6
|
+
>
|
|
7
|
+
<div
|
|
8
|
+
ref="editor"
|
|
9
|
+
@keydown.tab.prevent.stop="approveCompletion('all')"
|
|
10
|
+
@keydown.ctrl.right.prevent.stop="approveCompletion('word')"
|
|
11
|
+
@keydown.ctrl.down.prevent.stop="startCompletion()"
|
|
12
|
+
>
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script setup lang="ts">
|
|
21
|
+
import { onMounted, ref, onUnmounted, watch, type Ref } from "vue";
|
|
22
|
+
import { callAdminForthApi } from '@/utils';
|
|
23
|
+
import { AdminForthColumnCommon } from '@/types/Common';
|
|
24
|
+
import adminforth from '@/adminforth';
|
|
25
|
+
|
|
26
|
+
import AsyncQueue from './async-queue';
|
|
27
|
+
import Quill from "quill";
|
|
28
|
+
import "quill/dist/quill.snow.css";
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
function dbg(title: string,...args: any[]) {
|
|
32
|
+
// return; // comment for debug
|
|
33
|
+
console.log(title, ...args.map(a =>JSON.stringify(a, null, 1)));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// blots/embed: Represents inline embed elements, like images or videos that can be inserted into the text flow.
|
|
37
|
+
const Embed = Quill.import('blots/embed');
|
|
38
|
+
const BlockEmbed = Quill.import('blots/block/embed');
|
|
39
|
+
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
class CompleteBlot extends Embed {
|
|
42
|
+
static blotName = 'complete';
|
|
43
|
+
static tagName = 'span';
|
|
44
|
+
// https://stackoverflow.com/a/78434756/27379293
|
|
45
|
+
static className = "complete-blot";
|
|
46
|
+
|
|
47
|
+
static create(value: { text: string }) {
|
|
48
|
+
let node = super.create();
|
|
49
|
+
// we should keep contenteditable=true for case when user clicks on empty area
|
|
50
|
+
// node.setAttribute('contenteditable', 'false');
|
|
51
|
+
node.setAttribute('completer', '');
|
|
52
|
+
node.innerText = value.text;
|
|
53
|
+
return node;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static value(node: HTMLElement) {
|
|
57
|
+
return {
|
|
58
|
+
text: node.innerText,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
class ImageBlot extends BlockEmbed {
|
|
65
|
+
static blotName = 'image';
|
|
66
|
+
static tagName = 'img';
|
|
67
|
+
|
|
68
|
+
static create(value) {
|
|
69
|
+
let node = super.create();
|
|
70
|
+
node.setAttribute('alt', value.alt);
|
|
71
|
+
node.setAttribute('src', value.url);
|
|
72
|
+
node.setAttribute('data-s3path', value['s3Path']);
|
|
73
|
+
return node;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static value(node) {
|
|
77
|
+
return {
|
|
78
|
+
alt: node.getAttribute('alt'),
|
|
79
|
+
url: node.getAttribute('src'),
|
|
80
|
+
s3Path: node.getAttribute('data-s3path'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// @ts-ignore
|
|
86
|
+
Quill.register(CompleteBlot);
|
|
87
|
+
// @ts-ignore
|
|
88
|
+
Quill.register(ImageBlot);
|
|
89
|
+
|
|
90
|
+
const updaterQueue = new AsyncQueue();
|
|
91
|
+
|
|
92
|
+
const props = defineProps<{
|
|
93
|
+
column: AdminForthColumn,
|
|
94
|
+
record: any,
|
|
95
|
+
meta: any,
|
|
96
|
+
}>();
|
|
97
|
+
|
|
98
|
+
const emit = defineEmits([
|
|
99
|
+
'update:value',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
const currentValue: Ref<string> = ref('');
|
|
103
|
+
|
|
104
|
+
const editor = ref<HTMLElement>();
|
|
105
|
+
const completion = ref<string[] | null>(null);
|
|
106
|
+
let quill: any = null;
|
|
107
|
+
const editorFocused = ref(false);
|
|
108
|
+
|
|
109
|
+
let lastText: string | null = null;
|
|
110
|
+
|
|
111
|
+
const imageProgress = ref(0);
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async function saveToServer(file: File) {
|
|
115
|
+
const fd = new FormData();
|
|
116
|
+
fd.append('image', file);
|
|
117
|
+
|
|
118
|
+
const originalFilename = file.name.split('.').slice(0, -1).join('.');
|
|
119
|
+
const originalExtension = file.name.split('.').pop();
|
|
120
|
+
// send fd to s3
|
|
121
|
+
const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
|
|
122
|
+
path: `/plugin/${props.meta.uploadPluginInstanceId}/get_s3_upload_url`,
|
|
123
|
+
method: 'POST',
|
|
124
|
+
body: {
|
|
125
|
+
originalFilename,
|
|
126
|
+
contentType: file.type,
|
|
127
|
+
size: file.size,
|
|
128
|
+
originalExtension,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (error) {
|
|
133
|
+
adminforth.alert({
|
|
134
|
+
message: `File was not uploaded because of error: ${error}`,
|
|
135
|
+
variant: 'danger'
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const xhr = new XMLHttpRequest();
|
|
141
|
+
const success = await new Promise((resolve) => {
|
|
142
|
+
xhr.upload.onprogress = (e) => {
|
|
143
|
+
if (e.lengthComputable) {
|
|
144
|
+
imageProgress.value = Math.round((e.loaded / e.total) * 100);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
xhr.addEventListener('loadend', () => {
|
|
148
|
+
const success = xhr.readyState === 4 && xhr.status === 200;
|
|
149
|
+
// try to read response
|
|
150
|
+
resolve(success);
|
|
151
|
+
});
|
|
152
|
+
xhr.open('PUT', uploadUrl, true);
|
|
153
|
+
xhr.setRequestHeader('Content-Type', file.type);
|
|
154
|
+
xhr.setRequestHeader('x-amz-tagging', tagline);
|
|
155
|
+
xhr.send(file);
|
|
156
|
+
});
|
|
157
|
+
if (!success) {
|
|
158
|
+
adminforth.alert({
|
|
159
|
+
messageHtml: `<div>Sorry but the file was not uploaded because of S3 Request Error: </div>
|
|
160
|
+
<pre style="white-space: pre-wrap; word-wrap: break-word; overflow-wrap: break-word; max-width: 100%;">${
|
|
161
|
+
xhr.responseText.replace(/</g, '<').replace(/>/g, '>')
|
|
162
|
+
}</pre>`,
|
|
163
|
+
variant: 'danger',
|
|
164
|
+
timeout: 30,
|
|
165
|
+
});
|
|
166
|
+
imageProgress.value = 0;
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// here we have s3Path, call createResource to save the image
|
|
171
|
+
const range = quill.getSelection();
|
|
172
|
+
quill.insertEmbed(range.index, 'image', {
|
|
173
|
+
url: previewUrl,
|
|
174
|
+
s3Path: s3Path,
|
|
175
|
+
alt: file.name
|
|
176
|
+
}, 'user');
|
|
177
|
+
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function imageHandler() {
|
|
181
|
+
const input = document.createElement('input');
|
|
182
|
+
input.setAttribute('type', 'file');
|
|
183
|
+
input.click();
|
|
184
|
+
|
|
185
|
+
// Listen upload local image and save to server
|
|
186
|
+
input.onchange = () => {
|
|
187
|
+
const file = input.files[0];
|
|
188
|
+
|
|
189
|
+
// file type is only image.
|
|
190
|
+
if (/^image\//.test(file.type)) {
|
|
191
|
+
saveToServer(file);
|
|
192
|
+
} else {
|
|
193
|
+
console.warn('You could only upload images.');
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
onMounted(() => {
|
|
199
|
+
currentValue.value = props.record[props.column.name] || '';
|
|
200
|
+
editor.value.innerHTML = currentValue.value;
|
|
201
|
+
|
|
202
|
+
quill = new Quill(editor.value as HTMLElement, {
|
|
203
|
+
theme: "snow",
|
|
204
|
+
readOnly:props.column?.editReadonly,
|
|
205
|
+
placeholder: 'Type here...',
|
|
206
|
+
// formats : ['complete'],
|
|
207
|
+
modules: {
|
|
208
|
+
toolbar: {
|
|
209
|
+
container: props.meta.toolbar || [
|
|
210
|
+
['bold', 'italic', 'underline', 'strike'], // toggled buttons
|
|
211
|
+
['blockquote', 'code-block', 'link', ...props.meta.uploadPluginInstanceId ? ['image'] : []],
|
|
212
|
+
// [
|
|
213
|
+
// // 'image',
|
|
214
|
+
// // 'video',
|
|
215
|
+
// // 'formula'
|
|
216
|
+
// ],
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
[{ 'header': 2 }, { 'header': 3 }], // custom button values
|
|
220
|
+
[{ 'list': 'ordered'}, { 'list': 'bullet' }, { 'list': 'check' }],
|
|
221
|
+
// [{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
|
|
222
|
+
// [{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
|
|
223
|
+
// [{ 'direction': 'rtl' }], // text direction
|
|
224
|
+
|
|
225
|
+
// [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
|
|
226
|
+
// [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
|
227
|
+
|
|
228
|
+
// [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
|
|
229
|
+
// [{ 'font': [] }],
|
|
230
|
+
[{ 'align': [] }],
|
|
231
|
+
|
|
232
|
+
['clean'] // remove formatting button
|
|
233
|
+
],
|
|
234
|
+
handlers: {
|
|
235
|
+
image: imageHandler,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
keyboard: {
|
|
239
|
+
bindings: {
|
|
240
|
+
tab: {
|
|
241
|
+
key: 9,
|
|
242
|
+
handler: function (range: any, context: any) {
|
|
243
|
+
if (completion.value !== null) {
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
lastText = quill.getText();
|
|
254
|
+
|
|
255
|
+
quill.on(Quill.events.TEXT_CHANGE, async (delta: any, oldDelta: any, source: string) => {
|
|
256
|
+
dbg('๐ชฝ TEXT_CHANGE fired ', delta, oldDelta, source);
|
|
257
|
+
updaterQueue.add(emitTextUpdate);
|
|
258
|
+
startCompletion();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
quill.on('selection-change', (range: any, oldRange: any, source: string) => {
|
|
262
|
+
dbg('๐ชฝ selection changed', range, oldRange, source);
|
|
263
|
+
if (range === null) {
|
|
264
|
+
// blur event
|
|
265
|
+
removeCompletionOnBlur();
|
|
266
|
+
editorFocused.value = false;
|
|
267
|
+
return;
|
|
268
|
+
} else {
|
|
269
|
+
editorFocused.value = true;
|
|
270
|
+
startCompletion();
|
|
271
|
+
}
|
|
272
|
+
const text = quill.getText();
|
|
273
|
+
// don't allow to select after completion
|
|
274
|
+
// TODO
|
|
275
|
+
// if (range?.index === text.length) {
|
|
276
|
+
// console.log('RANGE IDX', range.index, text.length, 'text', JSON.stringify(text, null, 1));
|
|
277
|
+
// dbg('โ prevent selection after completion');
|
|
278
|
+
// quill.setSelection(text.length - 1, 0, 'silent');
|
|
279
|
+
// }
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
// handle right swipe on mobile uding document/window, and console log if swiped in right direction
|
|
284
|
+
if ('ontouchstart' in window) {
|
|
285
|
+
document.addEventListener('touchstart', handleTouchStart, false);
|
|
286
|
+
document.addEventListener('touchmove', handleTouchMove, false);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async function emitTextUpdate() {
|
|
293
|
+
const editorHtml = quill.root.innerHTML;
|
|
294
|
+
// remove completion from html
|
|
295
|
+
const html = editorHtml.replace(/<span[^>]*completer[^>]*>.*?<\/span>/g, '');
|
|
296
|
+
|
|
297
|
+
if (lastText === html) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
lastText = html;
|
|
302
|
+
|
|
303
|
+
await (new Promise((resolve) => setTimeout(resolve, 0)));
|
|
304
|
+
|
|
305
|
+
dbg('โฌ๏ธ emit value suggestion-input', html);
|
|
306
|
+
emit('update:value', html);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Auto-Completion functions
|
|
310
|
+
let tmt: null | ReturnType<typeof setTimeout> = null;
|
|
311
|
+
|
|
312
|
+
let xDown: null | number = null;
|
|
313
|
+
let yDown: null | number = null;
|
|
314
|
+
|
|
315
|
+
function handleTouchStart(evt: TouchEvent) {
|
|
316
|
+
xDown = evt.touches[0].clientX;
|
|
317
|
+
yDown = evt.touches[0].clientY;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function handleTouchMove(evt: TouchEvent) {
|
|
321
|
+
if (!xDown || !yDown) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let xUp = evt.touches[0].clientX;
|
|
326
|
+
let yUp = evt.touches[0].clientY;
|
|
327
|
+
|
|
328
|
+
let xDiff = xDown - xUp;
|
|
329
|
+
let yDiff = yDown - yUp;
|
|
330
|
+
|
|
331
|
+
if (Math.abs(xDiff) > Math.abs(yDiff)) {
|
|
332
|
+
if (xDiff < 0) {
|
|
333
|
+
// complete word if completion and input is focused
|
|
334
|
+
dbg('๐ swipe right', completion.value, editorFocused.value);
|
|
335
|
+
if (completion.value !== null && editorFocused.value) {
|
|
336
|
+
approveCompletion('word');
|
|
337
|
+
// [Intervention] Unable to preventDefault inside passive event listener due to target being treated as passive. See https://www.chromestatus.com/feature/5093566007214080
|
|
338
|
+
// evt.preventDefault();
|
|
339
|
+
evt.stopPropagation();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
xDown = null;
|
|
345
|
+
yDown = null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
onUnmounted(() => {
|
|
349
|
+
quill.off(Quill.events.TEXT_CHANGE);
|
|
350
|
+
quill.off('selection-change');
|
|
351
|
+
|
|
352
|
+
if ('ontouchstart' in window) {
|
|
353
|
+
document.removeEventListener('touchstart', handleTouchStart);
|
|
354
|
+
document.removeEventListener('touchmove', handleTouchMove);
|
|
355
|
+
}
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async function complete(textBeforeCursor: string) {
|
|
360
|
+
const res = await callAdminForthApi({
|
|
361
|
+
path: `/plugin/${props.meta.pluginInstanceId}/doComplete`,
|
|
362
|
+
method: 'POST',
|
|
363
|
+
body: {
|
|
364
|
+
record: {...props.record, [props.column.name]: textBeforeCursor},
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return res.completion;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function updateCompleteEmbed(text: string) {
|
|
372
|
+
const curCursorPos = quill.getSelection();
|
|
373
|
+
const d = quill.getContents();
|
|
374
|
+
const c = d.ops.find((op: any) => op.insert.complete);
|
|
375
|
+
if (!c) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
c.insert.complete.text = text;
|
|
379
|
+
quill.setContents(d.ops, 'silent');
|
|
380
|
+
quill.setSelection(curCursorPos.index, curCursorPos.length, 'silent');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function deleteCompleteEmbed() {
|
|
384
|
+
const completeNode = quill.root.querySelector('[completer]');
|
|
385
|
+
const completeBlot = Quill.find(completeNode);
|
|
386
|
+
const blotIdx: number | null = completeBlot ? quill.getIndex(completeBlot) : null;
|
|
387
|
+
|
|
388
|
+
dbg('๐ complete blot idx', blotIdx);
|
|
389
|
+
|
|
390
|
+
if (blotIdx !== null) {
|
|
391
|
+
quill.deleteText(blotIdx, 1, 'silent');
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function approveCompletion(type: 'all' | 'word') {
|
|
396
|
+
if (!props.meta.shouldComplete) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
dbg('๐จ approveCompletion')
|
|
401
|
+
|
|
402
|
+
if (completion.value === null) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const cursorPosition = quill.getSelection();
|
|
407
|
+
|
|
408
|
+
let shouldComplete = false;
|
|
409
|
+
if (type === 'all') {
|
|
410
|
+
dbg(`๐ insert all at ${cursorPosition.index}, ${completion.value.join('')}`);
|
|
411
|
+
deleteCompleteEmbed();
|
|
412
|
+
quill.insertText(cursorPosition.index, completion.value.join(''), 'silent');
|
|
413
|
+
shouldComplete = true;
|
|
414
|
+
} else {
|
|
415
|
+
const word = completion.value[0];
|
|
416
|
+
quill.insertText(cursorPosition.index, word, 'silent');
|
|
417
|
+
completion.value = completion.value.slice(1);
|
|
418
|
+
if (completion.value.length === 0) {
|
|
419
|
+
shouldComplete = true;
|
|
420
|
+
} else {
|
|
421
|
+
// update completion
|
|
422
|
+
// TODO probably better way to update Embed?
|
|
423
|
+
updateCompleteEmbed(completion.value.join(''));
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
updaterQueue.add(emitTextUpdate);
|
|
428
|
+
|
|
429
|
+
if (shouldComplete) {
|
|
430
|
+
startCompletion();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function startCompletion() {
|
|
436
|
+
if (!props.meta.shouldComplete || props.column?.editReadonly ) {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
completion.value = null;
|
|
440
|
+
deleteCompleteEmbed();
|
|
441
|
+
|
|
442
|
+
if (tmt) {
|
|
443
|
+
clearTimeout(tmt);
|
|
444
|
+
}
|
|
445
|
+
tmt = setTimeout(async () => {
|
|
446
|
+
const currentTmt = tmt;
|
|
447
|
+
const cursorPosition = quill.getSelection();
|
|
448
|
+
dbg('๐ get pos', cursorPosition.index, cursorPosition.length)
|
|
449
|
+
if (cursorPosition.length !== 0) {
|
|
450
|
+
// we will not complete if text selected
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const charAfterCursor = quill.getText(cursorPosition.index, 1);
|
|
455
|
+
dbg('๐ charAfterCursor', charAfterCursor);
|
|
456
|
+
if (charAfterCursor !== '\n') {
|
|
457
|
+
// we will not complete if not at the end of the line
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const textBeforeCursor = quill.getText(0, cursorPosition.index);
|
|
462
|
+
|
|
463
|
+
const completionAnswer = await complete(textBeforeCursor);
|
|
464
|
+
if (currentTmt !== tmt) {
|
|
465
|
+
// while we were waiting for completion, new completion was started
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
quill.insertEmbed(cursorPosition.index, 'complete', { text: completionAnswer.join('') }, 'silent');
|
|
470
|
+
|
|
471
|
+
//dbg('๐ set pos', cursorPosition.index, cursorPosition.length)
|
|
472
|
+
//quill.setSelection(cursorPosition.index, cursorPosition.length, 'silent');
|
|
473
|
+
|
|
474
|
+
completion.value = completionAnswer;
|
|
475
|
+
|
|
476
|
+
dbg('๐ completion finished', quill.getContents());
|
|
477
|
+
|
|
478
|
+
}, props.meta.debounceTime || 300);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function removeCompletionOnBlur() {
|
|
482
|
+
if (lastText?.trim().length === 0) {
|
|
483
|
+
completion.value = null;
|
|
484
|
+
const d = quill.getContents();
|
|
485
|
+
const i = d.ops.findIndex((op: any) => op.insert.complete);
|
|
486
|
+
if (i !== -1) {
|
|
487
|
+
d.ops.splice(i, 1);
|
|
488
|
+
quill.setContents(d, 'silent');
|
|
489
|
+
dbg('๐งน Cleaned completion from ops to make ph visible');
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
</script>
|
|
495
|
+
|
|
496
|
+
<style lang="scss">
|
|
497
|
+
|
|
498
|
+
.af-quill-editor {
|
|
499
|
+
|
|
500
|
+
.ql-toolbar.ql-snow[class] {
|
|
501
|
+
border: none;
|
|
502
|
+
padding: 0 0 1rem 0;
|
|
503
|
+
.ql-picker-label{
|
|
504
|
+
padding-left: 0;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.ql-container {
|
|
509
|
+
border: 0;
|
|
510
|
+
.ql-editor {
|
|
511
|
+
position: relative;
|
|
512
|
+
padding: 0;
|
|
513
|
+
min-height: 100px;
|
|
514
|
+
&.ql-blank::before {
|
|
515
|
+
left: 0px;
|
|
516
|
+
font-style: normal;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// .ql-editor:not(:focus) [completer] {
|
|
522
|
+
// display: none;
|
|
523
|
+
// }
|
|
524
|
+
|
|
525
|
+
.ql-editor [completer] {
|
|
526
|
+
color: gray;
|
|
527
|
+
font-style: italic;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
.ql-editor p {
|
|
531
|
+
margin-bottom: 0.5rem;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.ql-snow .ql-stroke {
|
|
535
|
+
@apply dark:stroke-darkPrimary;
|
|
536
|
+
@apply stroke-lightPrimary;
|
|
537
|
+
|
|
538
|
+
}
|
|
539
|
+
.ql-snow button:hover .ql-stroke,
|
|
540
|
+
.ql-snow [role="button"]:hover .ql-stroke {
|
|
541
|
+
@apply dark:stroke-darkPrimary;
|
|
542
|
+
@apply stroke-lightPrimary;
|
|
543
|
+
filter: brightness(1.3);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.ql-snow .ql-fill {
|
|
547
|
+
@apply dark:fill-darkPrimary;
|
|
548
|
+
@apply fill-lightPrimary;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.ql-snow button:hover .ql-fill {
|
|
552
|
+
@apply dark:fill-darkPrimary;
|
|
553
|
+
@apply fill-lightPrimary;
|
|
554
|
+
filter: brightness(1.3);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
</style>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"baseUrl": ".", // This should point to your project root
|
|
4
|
+
"paths": {
|
|
5
|
+
"@/*": [
|
|
6
|
+
// "node_modules/adminforth/dist/spa/src/*"
|
|
7
|
+
"../../../spa/src/*"
|
|
8
|
+
],
|
|
9
|
+
"*": [
|
|
10
|
+
// "node_modules/adminforth/dist/spa/node_modules/*"
|
|
11
|
+
"../../../spa/node_modules/*"
|
|
12
|
+
],
|
|
13
|
+
"@@/*": [
|
|
14
|
+
// "node_modules/adminforth/dist/spa/src/*"
|
|
15
|
+
"."
|
|
16
|
+
]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adminforth/rich-editor",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"description": "Rich editor plugin for adminforth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"prepare": "npm link adminforth",
|
|
10
|
-
"build": "tsc"
|
|
10
|
+
"build": "tsc && rsync -av --exclude 'node_modules' custom dist/"
|
|
11
11
|
},
|
|
12
|
+
"homepage": "https://adminforth.dev/docs/tutorial/Plugins/RichEditor/",
|
|
12
13
|
"repository": {
|
|
13
14
|
"type": "git",
|
|
14
15
|
"url": "https://github.com/devforth/adminforth-rich-editor.git"
|