@mirus/tiptap-editor 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,501 @@
1
+ <template>
2
+ <div>
3
+ <div class="tiptap-editor" tabindex="0">
4
+ <div
5
+ v-if="showMenu && editor"
6
+ class="menubar"
7
+ role="toolbar"
8
+ :aria-controls="id || null"
9
+ >
10
+ <button
11
+ :aria-pressed="`${editor.isActive('bold') ? 'true' : 'false'}`"
12
+ :class="{ 'is-active': editor.isActive('bold') }"
13
+ @keyup.left="toolbarGoLeft"
14
+ @keyup.right="toolbarGoRight"
15
+ @click="editor.chain().focus().toggleBold().run()"
16
+ aria-label="bold"
17
+ value="bold"
18
+ type="button"
19
+ >
20
+ <b>B</b>
21
+ </button>
22
+ <button
23
+ :aria-pressed="`${editor.isActive('italic') ? 'true' : 'false'}`"
24
+ :class="{ 'is-active': editor.isActive('italic') }"
25
+ @click="editor.chain().focus().toggleItalic().run()"
26
+ @keyup.left="toolbarGoLeft"
27
+ @keyup.right="toolbarGoRight"
28
+ value="italic"
29
+ type="button"
30
+ >
31
+ <i>I</i>
32
+ </button>
33
+ <button
34
+ @click="editor.chain().focus().toggleBulletList().run()"
35
+ :class="{ 'is-active': editor.isActive('bulletList') }"
36
+ @keyup.left="toolbarGoLeft"
37
+ @keyup.right="toolbarGoRight"
38
+ aria-label="bullet list"
39
+ value="bulletlist"
40
+ type="button"
41
+ >
42
+ <svg
43
+ aria-hidden="true"
44
+ focusable="false"
45
+ data-prefix="fas"
46
+ data-icon="list-ul"
47
+ class="svg-inline--fa fa-list-ul fa-w-16"
48
+ role="img"
49
+ xmlns="http://www.w3.org/2000/svg"
50
+ viewBox="0 0 512 512"
51
+ >
52
+ <path
53
+ fill="currentColor"
54
+ d="M96 96c0 26.51-21.49 48-48 48S0 122.51 0 96s21.49-48 48-48 48 21.49 48 48zM48 208c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm0 160c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm96-236h352c8.837 0 16-7.163 16-16V76c0-8.837-7.163-16-16-16H144c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h352c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H144c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16zm0 160h352c8.837 0 16-7.163 16-16v-40c0-8.837-7.163-16-16-16H144c-8.837 0-16 7.163-16 16v40c0 8.837 7.163 16 16 16z"
55
+ ></path>
56
+ </svg>
57
+ </button>
58
+ <div class="character-count" v-if="maxCharacterCount && editor">
59
+ <svg
60
+ height="20"
61
+ width="20"
62
+ viewBox="0 0 20 20"
63
+ :class="
64
+ maxCharacterCountExceeded
65
+ ? 'character-count__graph--warning'
66
+ : 'character-count__graph'
67
+ "
68
+ >
69
+ <circle r="10" cx="10" cy="10" fill="#e9ecef" />
70
+ <circle
71
+ r="5"
72
+ cx="10"
73
+ cy="10"
74
+ fill="transparent"
75
+ stroke="currentColor"
76
+ stroke-width="10"
77
+ :stroke-dasharray="`calc(${characterCountPercentage} * 31.4 / 100) 31.4`"
78
+ transform="rotate(-90) translate(-20)"
79
+ />
80
+ <circle r="6" cx="10" cy="10" fill="white" />
81
+ </svg>
82
+ <div class="character-count__text" aria-live="polite">
83
+ {{ editor.storage.characterCount.characters() }} /
84
+ {{ maxCharacterCount }} characters
85
+ </div>
86
+ </div>
87
+ </div>
88
+ <editor-content
89
+ :editor="editor"
90
+ :style="{ height: height }"
91
+ :id="id || null"
92
+ role="textbox"
93
+ class="editor__content"
94
+ aria-label="text area"
95
+ tabindex="-1"
96
+ />
97
+ </div>
98
+ <div class="error-list" :v-show="false" ref="errors">
99
+ <template v-if="currentWarning">
100
+ <b>{{ currentWarning.message }}</b>
101
+ <div
102
+ v-for="(option, index) in currentOptions"
103
+ :key="option.id"
104
+ class="error-list__item"
105
+ :class="{ selected: navigatedOptionIndex === index }"
106
+ @click="selectOption(option)"
107
+ >
108
+ {{ option.value }}
109
+ </div>
110
+ </template>
111
+ </div>
112
+ </div>
113
+ </template>
114
+
115
+ <script>
116
+ import 'current-script-polyfill';
117
+ import { Editor, EditorContent } from '@tiptap/vue-2';
118
+ import Bold from '@tiptap/extension-bold';
119
+ import BulletList from '@tiptap/extension-bullet-list';
120
+ import CharacterCount from '@tiptap/extension-character-count';
121
+ import Document from '@tiptap/extension-document';
122
+ import Italic from '@tiptap/extension-italic';
123
+ import History from '@tiptap/extension-history';
124
+ import ListItem from '@tiptap/extension-list-item';
125
+ import Paragraph from '@tiptap/extension-paragraph';
126
+ import Placeholder from '@tiptap/extension-placeholder';
127
+ import Text from '@tiptap/extension-text';
128
+ import tippy from 'tippy.js';
129
+ import Warning from './warnings';
130
+ import unescape from 'lodash.unescape';
131
+
132
+ export default {
133
+ name: 'tiptapEditor',
134
+ props: {
135
+ height: {
136
+ type: String,
137
+ default: '300px',
138
+ },
139
+ id: { type: String, default: null },
140
+ value: { type: String, default: '' },
141
+ warnings: {
142
+ type: Array,
143
+ default: () => [],
144
+ },
145
+ maxCharacterCount: {
146
+ type: Number,
147
+ default: null,
148
+ },
149
+ placeholder: { type: String, default: 'write your content here...' },
150
+ showMenu: {
151
+ type: Boolean,
152
+ default: true,
153
+ },
154
+ },
155
+ components: { EditorContent },
156
+ data() {
157
+ return {
158
+ editor: null,
159
+ currentWarning: null,
160
+ currentOptions: null,
161
+ currentValue: '',
162
+ navigatedOptionIndex: 0,
163
+ insertOption: () => {},
164
+ optionsRange: null,
165
+ currentCharacterCount: 0,
166
+ };
167
+ },
168
+ computed: {
169
+ errors() {
170
+ if (this.warnings.length < 1) {
171
+ return [];
172
+ }
173
+ return this.warnings.map((mistake) => {
174
+ const isWord = mistake.isWord === undefined ? true : mistake.isWord;
175
+ return {
176
+ overrideClass: mistake.overrideClass,
177
+ isWord: isWord,
178
+ value: isWord ? mistake.value : unescape(mistake.value),
179
+ message: mistake.message,
180
+ options: (mistake.options || []).map((value, id) => ({ value, id })),
181
+ };
182
+ });
183
+ },
184
+ maxCharacterCountExceeded() {
185
+ if (this.editor) {
186
+ return this.editor.storage.characterCount.characters() >= this.maxCharacterCount;
187
+ }
188
+ },
189
+ characterCountPercentage() {
190
+ if (this.editor) {
191
+ return Math.round(
192
+ (100 / this.maxCharacterCount) * this.editor.storage.characterCount.characters()
193
+ );
194
+ }
195
+ },
196
+ },
197
+ mounted() {
198
+ this.currentValue = this.value;
199
+ this.editor = new Editor({
200
+ content: this.value,
201
+ parseOptions: { preserveWhitespace: 'full' },
202
+ onUpdate: ({ getJSON, getHTML }) => {
203
+ this.currentValue = this.editor.getHTML();
204
+ this.$emit('update:value', this.currentValue);
205
+ },
206
+ extensions: [
207
+ Bold,
208
+ BulletList,
209
+ CharacterCount.configure({ limit: this.maxCharacterCount }),
210
+ Document,
211
+ History,
212
+ Italic,
213
+ ListItem,
214
+ Paragraph,
215
+ Placeholder.configure({
216
+ placeholder: this.placeholder,
217
+ }),
218
+ Text,
219
+ Warning.configure({
220
+ getErrorWords: this.getErrorWords,
221
+ onEnter: ({ range, command, virtualNode, text }) => {
222
+ this.currentWarning = this.errors.find((err) => err.value === text);
223
+ this.currentOptions = this.currentWarning.options || [];
224
+ this.navigatedOptionIndex = 0;
225
+ this.optionRange = range;
226
+ this.renderPopup(virtualNode);
227
+ this.insertOption = command;
228
+ },
229
+ onChange: ({ range, virtualNode, text }) => {
230
+ this.currentWarning = this.errors.find((err) => err.value === text);
231
+ this.currentOptions = this.currentWarning.options || [];
232
+ this.navigatedOptionIndex = 0;
233
+ this.optionRange = range;
234
+ this.renderPopup(virtualNode);
235
+ },
236
+ onExit: () => {
237
+ this.navigatedOptionIndex = 0;
238
+ this.currentOptions = null;
239
+ this.optionRange = null;
240
+ this.destroyPopup();
241
+ },
242
+ onKeyDown: ({ event }) => {
243
+ // pressing up arrow
244
+ if (event.keyCode === 38 && this.currentOptions !== null) {
245
+ this.upHandler();
246
+ return true;
247
+ }
248
+ // pressing down arrow
249
+ if (event.keyCode === 40 && this.currentOptions !== null) {
250
+ this.downHandler();
251
+ return true;
252
+ }
253
+ // pressing enter
254
+ if (event.keyCode === 13) {
255
+ return this.enterHandler();
256
+ }
257
+ // pressing escape
258
+ if (event.keyCode === 27) {
259
+ this.navigatedOptionIndex = 0;
260
+ this.optionRange = null;
261
+ this.currentOptions = null;
262
+ this.destroyPopup();
263
+ return true;
264
+ }
265
+ return false;
266
+ },
267
+ }),
268
+ ],
269
+ });
270
+ tippy.setDefaults({
271
+ content: this.$refs.errors,
272
+ trigger: 'mouseenter',
273
+ interactive: true,
274
+ theme: 'dark',
275
+ placement: 'top-start',
276
+ performance: true,
277
+ inertia: true,
278
+ duration: [400, 200],
279
+ showOnInit: true,
280
+ arrow: true,
281
+ arrowType: 'round',
282
+ hideOnClick: false,
283
+ });
284
+ },
285
+ destroyed() {
286
+ this.editor.destroy();
287
+ if (this.popup) {
288
+ this.popup.destroy();
289
+ }
290
+ },
291
+ methods: {
292
+ getErrorWords() {
293
+ if (this.errors.length < 1) {
294
+ return [];
295
+ }
296
+ return this.errors.map((err) => ({
297
+ value: err.value,
298
+ overrideClass: err.overrideClass,
299
+ isWord: err.isWord,
300
+ }));
301
+ },
302
+ upHandler() {
303
+ this.navigatedOptionIndex =
304
+ (this.navigatedOptionIndex + this.currentOptions.length - 1) %
305
+ this.currentOptions.length;
306
+ },
307
+ downHandler() {
308
+ this.navigatedOptionIndex =
309
+ (this.navigatedOptionIndex + 1) % this.currentOptions.length;
310
+ },
311
+ enterHandler() {
312
+ if (this.currentOptions.length === 0) {
313
+ return false;
314
+ }
315
+
316
+ const option = this.currentOptions[this.navigatedOptionIndex];
317
+ if (option) {
318
+ this.selectOption(option);
319
+ }
320
+ return true;
321
+ },
322
+ selectOption(option) {
323
+ this.insertOption({
324
+ range: this.optionRange,
325
+ attrs: {
326
+ id: option.id,
327
+ label: option.value,
328
+ },
329
+ });
330
+ this.editor.commands.focus();
331
+ },
332
+ renderPopup(node) {
333
+ if (!this.popup) {
334
+ this.popup = tippy(node, { content: this.$refs.errors });
335
+ }
336
+ },
337
+ destroyPopup() {
338
+ if (this.popup) {
339
+ this.popup.destroy();
340
+ this.popup = null;
341
+ }
342
+ },
343
+ toolbarGoLeft(evt) {
344
+ evt.preventDefault();
345
+ const prevSibling = evt.target.previousSibling;
346
+
347
+ if (prevSibling && prevSibling.focus !== undefined) {
348
+ prevSibling.focus();
349
+ }
350
+ },
351
+ toolbarGoRight(evt) {
352
+ evt.preventDefault();
353
+ const nextSibling = evt.target.nextSibling;
354
+
355
+ if (nextSibling && nextSibling.focus !== undefined) {
356
+ nextSibling.focus();
357
+ }
358
+ },
359
+ },
360
+ watch: {
361
+ warnings: function (n, o) {
362
+ if (this.editor) {
363
+ // preserve selection after updating warnings
364
+ const oldSelection = this.editor.selection;
365
+ this.editor.setContent(this.currentValue);
366
+ this.editor.setSelection(oldSelection.from, oldSelection.to);
367
+ }
368
+ },
369
+ },
370
+ };
371
+ </script>
372
+
373
+ <style lang="scss">
374
+ .error-list {
375
+ .error-list__item {
376
+ &.selected,
377
+ &:hover {
378
+ background-color: rgba(white, 0.2);
379
+ }
380
+ }
381
+ }
382
+
383
+ .tiptap-editor {
384
+ border: 1px solid #e5e7eb;
385
+ border-radius: 8px;
386
+ padding: 4px;
387
+
388
+ p.is-empty:first-child::before {
389
+ content: attr(data-placeholder);
390
+ float: left;
391
+ color: #aaa;
392
+ pointer-events: none;
393
+ height: 0;
394
+ }
395
+
396
+ .menubar {
397
+ // border-bottom: 1px solid #e5e7eb;
398
+ padding: 4px;
399
+ border-radius: 4px;
400
+ background-color: #f4f4f5;
401
+ display: flex;
402
+
403
+ button {
404
+ font-size: 14px;
405
+ background-color: transparent;
406
+ border: none;
407
+ cursor: pointer;
408
+ height: 30px;
409
+ outline: 50;
410
+ width: 35px;
411
+ vertical-align: bottom;
412
+ border-radius: 4px;
413
+ margin-right: 3px;
414
+
415
+ &:focus {
416
+ outline: 2px solid #3b82f6;
417
+ transition: all 0.08s ease-in-out;
418
+ }
419
+
420
+ &.is-active {
421
+ background-color: #d3e3fd;
422
+ }
423
+
424
+ &.is-active:focus {
425
+ background-color: #bfd2f9;
426
+ }
427
+
428
+ &:not(.is-active):hover {
429
+ background-color: #e5e7eb;
430
+ }
431
+
432
+ &:not(.is-active):focus {
433
+ background-color: #e5e7eb;
434
+ }
435
+
436
+ svg {
437
+ width: 12px;
438
+ }
439
+ }
440
+ }
441
+
442
+ .editor__content {
443
+ font-size: 16px;
444
+ outline: 0;
445
+ overflow-y: auto;
446
+ padding: 10px;
447
+
448
+ .underline-red {
449
+ border-bottom: 3px red solid;
450
+ }
451
+
452
+ .underline-orange {
453
+ border-bottom: 3px orange solid;
454
+ }
455
+
456
+ .underline-green {
457
+ border-bottom: 3px lightgreen solid;
458
+ }
459
+
460
+ .underline-blue {
461
+ border-bottom: 3px #3b82f6 solid;
462
+ }
463
+
464
+ ul {
465
+ padding: 0px 40px;
466
+ }
467
+
468
+ .ProseMirror {
469
+ height: 100%;
470
+ padding: 2px;
471
+ border-radius: 7px;
472
+
473
+ &:focus {
474
+ outline: 2px solid #3b82f6;
475
+ transition: all 0.08s ease-in-out;
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ .character-count {
482
+ padding: 4px;
483
+ border-radius: 4px;
484
+ /* background-color: #fafafa; */
485
+ text-align: right;
486
+ padding-right: 15px;
487
+ display: flex;
488
+ gap: 8px;
489
+ justify-content: flex-end;
490
+ /* color: #71717a; */
491
+ flex-grow: 2;
492
+
493
+ &__graph {
494
+ color: #a8c2f7;
495
+
496
+ &--warning {
497
+ color: #fb7373;
498
+ }
499
+ }
500
+ }
501
+ </style>