quasar-ui-sellmate-ui-kit 3.11.1 → 3.11.2

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.
Files changed (84) hide show
  1. package/.eslintrc.cjs +104 -104
  2. package/.prettierrc +25 -25
  3. package/README.md +156 -156
  4. package/dist/index.common.js +2 -2
  5. package/dist/index.css +1 -1
  6. package/dist/index.esm.js +2 -2
  7. package/dist/index.min.css +1 -1
  8. package/dist/index.rtl.css +1 -1
  9. package/dist/index.rtl.min.css +1 -1
  10. package/dist/index.umd.js +4417 -4405
  11. package/dist/index.umd.min.js +2 -2
  12. package/html/assets/index-iPSQW1bz.css +1 -1
  13. package/html/assets/index-pzCGhZhc.js +35 -35
  14. package/html/favicon.svg +5 -5
  15. package/html/index.html +26 -26
  16. package/package.json +95 -95
  17. package/src/assets/icons.js +63 -63
  18. package/src/components/SBadge.vue +75 -75
  19. package/src/components/SButton.vue +206 -206
  20. package/src/components/SButtonGroup.vue +41 -41
  21. package/src/components/SButtonToggle.vue +200 -200
  22. package/src/components/SCaution.vue +143 -143
  23. package/src/components/SCheckbox.vue +123 -123
  24. package/src/components/SChip.vue +99 -99
  25. package/src/components/SDate.vue +664 -664
  26. package/src/components/SDateAutoRangePicker.vue +311 -311
  27. package/src/components/SDatePicker.vue +465 -465
  28. package/src/components/SDateRange.vue +382 -382
  29. package/src/components/SDateRangePicker.vue +582 -582
  30. package/src/components/SDateTimePicker.vue +259 -259
  31. package/src/components/SDialog.vue +250 -250
  32. package/src/components/SDropdown.vue +273 -273
  33. package/src/components/SEditor.vue +590 -590
  34. package/src/components/SFilePicker.vue +207 -207
  35. package/src/components/SHelp.vue +126 -126
  36. package/src/components/SHelpMessage.vue +57 -57
  37. package/src/components/SInput.vue +379 -379
  38. package/src/components/SInputCounter.vue +46 -46
  39. package/src/components/SInputNumber.vue +194 -194
  40. package/src/components/SList.vue +29 -29
  41. package/src/components/SMarkupTable.vue +142 -142
  42. package/src/components/SPagination.vue +345 -345
  43. package/src/components/SRadio.vue +78 -78
  44. package/src/components/SRouteTab.vue +67 -67
  45. package/src/components/SSelect.vue +299 -299
  46. package/src/components/SSelectCheckbox.vue +238 -238
  47. package/src/components/SSelectCustom.vue +189 -189
  48. package/src/components/SSelectGroupCheckbox.vue +372 -372
  49. package/src/components/SSelectSearchAutoComplete.vue +172 -172
  50. package/src/components/SSelectSearchCheckbox.vue +360 -360
  51. package/src/components/SStringToInput.vue +66 -66
  52. package/src/components/STab.vue +147 -147
  53. package/src/components/STable.vue +21 -9
  54. package/src/components/STableTree.vue +502 -502
  55. package/src/components/STabs.vue +32 -32
  56. package/src/components/STag.vue +152 -152
  57. package/src/components/STimePicker.vue +186 -186
  58. package/src/components/SToggle.vue +68 -68
  59. package/src/components/STooltip.vue +209 -209
  60. package/src/components/SYearMonthPicker.vue +211 -211
  61. package/src/components/SelelctItem.vue +21 -21
  62. package/src/components/TableTreeNode.vue +177 -177
  63. package/src/components/TimePickerCard.vue +393 -393
  64. package/src/components/__tests__/SButton.test.js +18 -18
  65. package/src/components/__tests__/SInput.test.js +42 -42
  66. package/src/components/__tests__/SInputCounter.test.js +30 -30
  67. package/src/components/__tests__/SInputNumber.test.js +32 -32
  68. package/src/components/__tests__/STimePicker.spec.js +78 -78
  69. package/src/composables/date.js +11 -11
  70. package/src/composables/modelBinder.js +13 -13
  71. package/src/composables/resizable.js +55 -55
  72. package/src/composables/table/use-navigator.js +110 -110
  73. package/src/composables/table/use-resizable-tree.js +96 -96
  74. package/src/composables/table/use-resizable.js +187 -187
  75. package/src/constants/locale.js +102 -102
  76. package/src/css/app.scss +106 -106
  77. package/src/css/default.scss +393 -393
  78. package/src/css/extends.scss +177 -177
  79. package/src/css/quasar.variables.scss +187 -187
  80. package/src/directives/Directive.js +7 -7
  81. package/src/index.scss +3 -3
  82. package/src/vue-plugin.js +93 -93
  83. package/tsconfig.json +35 -35
  84. package/vitest.config.ts +31 -31
@@ -1,590 +1,590 @@
1
- <template>
2
- <q-editor
3
- ref="editorRef"
4
- :content-style="{
5
- 'font-size': `${defaultFontSize}px`,
6
- resize: resizable ? 'vertical' : 'none',
7
- }"
8
- :min-height="`${minHeight}px`"
9
- :max-height="maxHeight != null ? `${maxHeight}px` : null"
10
- paragraph-tag="p"
11
- v-bind="$attrs"
12
- :definitions="toolDefinitions"
13
- :toolbar="toolbarDefinitions"
14
- v-model="model"
15
- class="s-editor"
16
- v-on="usePlaintextPasting ? { paste: onPaste } : {}"
17
- >
18
- <template #upload>
19
- <q-btn flat dense :ripple="false" size="sm">
20
- <q-icon name="image" />
21
- <q-menu>
22
- <q-card class="q-pa-sm bg-Grey_Lighten-5">
23
- <q-card-section class="q-pa-none">
24
- <q-input
25
- class="bg-white font-12-400"
26
- style="width: 352px"
27
- v-model="imgValue"
28
- dense
29
- outlined
30
- type="textarea"
31
- :placeholder="imgInputPlaceholder"
32
- autofocus
33
- />
34
- <div class="row reverse q-mt-sm">
35
- <s-button
36
- outline
37
- :label="imgUploadBtnLabel"
38
- @click="onOkClick"
39
- v-close-popup
40
- color="Blue_B_Default"
41
- class="bg-white"
42
- />
43
- </div>
44
- </q-card-section>
45
- </q-card>
46
- </q-menu>
47
- </q-btn>
48
- </template>
49
- </q-editor>
50
- </template>
51
-
52
- <script>
53
- import { defineComponent, ref, watch, computed, onMounted } from 'vue';
54
- import {
55
- extend,
56
- QCard,
57
- QCardSection,
58
- QMenu,
59
- QIcon,
60
- QEditor,
61
- QInput,
62
- QBtn,
63
- useQuasar,
64
- } from 'quasar';
65
- import resizeImage from '../composables/resizable';
66
-
67
- export default defineComponent({
68
- name: 'SEditor',
69
- components: {
70
- QEditor,
71
- QMenu,
72
- QCard,
73
- QCardSection,
74
- QInput,
75
- QBtn,
76
- QIcon,
77
- },
78
- props: {
79
- definitions: Object,
80
- toolbar: {
81
- type: Array,
82
- validator: v => v.length === 0 || v.every(group => group.length),
83
- default: () => [
84
- ['left', 'center', 'right', 'justify'],
85
- ['bold', 'italic', 'underline', 'strike'],
86
- ['link', 'hr'],
87
- ['fontSizes', 'removeFormat'],
88
- ['unordered', 'ordered', 'outdent', 'indent'],
89
- ['undo', 'redo'],
90
- ['viewsource'],
91
- ],
92
- },
93
- defaultFontSize: {
94
- type: Number,
95
- default: 13,
96
- },
97
- fontSizes: {
98
- type: Object,
99
- default: null,
100
- },
101
- resizable: {
102
- type: Boolean,
103
- default: false,
104
- },
105
- minHeight: {
106
- type: Number,
107
- default: 250,
108
- },
109
- maxHeight: {
110
- type: Number,
111
- default: null,
112
- },
113
- modelValue: {
114
- type: String,
115
- required: true,
116
- },
117
- useImgUpload: {
118
- type: Boolean,
119
- default: false,
120
- },
121
- imgUploadBtnLabel: {
122
- type: String,
123
- default: '삽입',
124
- },
125
- imgInputPlaceholder: {
126
- type: String,
127
- default:
128
- '이미지 파일 경로를 엔터키로 구분하여 여러개 입력할 수 있습니다.\n예시)\nhttps://www.sellmate.co.kr/img/new/logo01.png\nhttps://www.sellmate.co.kr/img/new/logo02.png\nhttps://www.sellmate.co.kr/img/new/logo03.png',
129
- },
130
- usePlaintextPasting: {
131
- type: Boolean,
132
- default: false,
133
- },
134
- imgResizable: {
135
- type: Boolean,
136
- default: false,
137
- },
138
- },
139
- setup(props, { emit }) {
140
- const model = ref(props.modelValue);
141
- const editorRef = ref(null);
142
-
143
- // delete handler element
144
- function unwrapImg() {
145
- const contentEl = editorRef.value.getContentEl();
146
- const resizeHandler = contentEl.querySelectorAll('.img-resize-handler');
147
-
148
- resizeHandler.forEach(div => {
149
- const img = div.querySelector('img');
150
- const parentParagraph = div.parentNode;
151
- parentParagraph.replaceChild(img, div);
152
- });
153
-
154
- model.value = contentEl.innerHTML;
155
- }
156
-
157
- // create handler element & resize event
158
- function wrapImgWithDiv(targetImg) {
159
- const newDiv = document.createElement('div');
160
- const className = 'img-resize-handler';
161
- newDiv.classList.add(className);
162
- newDiv.style.height = `${targetImg.height}px`;
163
- newDiv.style.width = `${targetImg.width}px`;
164
- targetImg.parentNode.insertBefore(newDiv, targetImg);
165
- newDiv.append(targetImg);
166
- resizeImage(className);
167
- }
168
-
169
- // add click event in contents
170
- function addImageClickListener() {
171
- if (!editorRef.value) {
172
- return;
173
- }
174
- const contentEl = editorRef.value.getContentEl();
175
-
176
- if (!contentEl) {
177
- return;
178
- }
179
-
180
- contentEl.addEventListener('click', event => {
181
- const images = contentEl.querySelectorAll('img');
182
- if (!images.length) {
183
- return;
184
- }
185
-
186
- // when clicked, check image document in contents
187
- if (Array.from(images).some(el => el.contains(event.target))) {
188
- wrapImgWithDiv(event.target);
189
- return;
190
- }
191
-
192
- unwrapImg();
193
- });
194
- }
195
-
196
- onMounted(() => {
197
- if (!props.imgResizable) return;
198
- const contentEl = editorRef.value.getContentEl();
199
- if (contentEl) {
200
- addImageClickListener();
201
- }
202
- });
203
-
204
- const $q = useQuasar();
205
-
206
- // Toolbar Begin
207
- const imgValue = ref('');
208
-
209
- function setFontSize(fontSize) {
210
- function isRootNode(node) {
211
- return (
212
- node.nodeType !== Node.TEXT_NODE &&
213
- (node.className.indexOf('q-editor__content') >= 0 ||
214
- node.hasAttribute('contenteditable'))
215
- );
216
- }
217
-
218
- function wrapAndReplaceRange(range) {
219
- const content = document.createTextNode(range.toString());
220
- const wrapperNode = document.createElement('span');
221
- wrapperNode.append(content);
222
-
223
- range.deleteContents();
224
- range.insertNode(wrapperNode);
225
-
226
- return wrapperNode;
227
- }
228
-
229
- function wrapAndReplaceTextNodeRange(node) {
230
- const nodeRange = document.createRange();
231
- nodeRange.setStart(node, 0);
232
- nodeRange.setEnd(node, node.length);
233
- return wrapAndReplaceRange(nodeRange);
234
- }
235
-
236
- function wrapTextNode(node) {
237
- const wrapper = document.createElement('span');
238
- wrapper.innerText = node.textContent;
239
- return wrapper;
240
- }
241
-
242
- function getNonTextNode(top, node) {
243
- const parent = node.parentNode;
244
- if (
245
- top !== parent &&
246
- Array.from(parent.childNodes).length === 1 &&
247
- parent.childNodes[0] === node
248
- ) {
249
- return parent;
250
- }
251
- return wrapAndReplaceTextNodeRange(node);
252
- }
253
-
254
- function getDirectChildNode(parent, node) {
255
- if (node.parentNode === parent) {
256
- return node;
257
- }
258
- return getDirectChildNode(parent, node.parentNode);
259
- }
260
-
261
- function removeChildrenFontStyle(node) {
262
- node.childNodes.forEach(child => {
263
- if (child.nodeType !== Node.TEXT_NODE) {
264
- child.style.fontSize = null;
265
- removeChildrenFontStyle(child);
266
- }
267
- });
268
- }
269
-
270
- function applyStyleToChildNode(fontSizeStyle, node, childIndex) {
271
- const childNode = node.childNodes[childIndex];
272
- if (childNode.nodeType === Node.TEXT_NODE) {
273
- const nodeWrapper = wrapTextNode(childNode);
274
- node.replaceChild(nodeWrapper, childNode);
275
- }
276
- node.childNodes[childIndex].style.fontSize = fontSizeStyle;
277
- removeChildrenFontStyle(node.childNodes[childIndex]);
278
- }
279
-
280
- function applyStyleToSiblings(fontSizeStyle, top, node, toBackward = false) {
281
- const parent = node.parentNode;
282
- if (top === parent) return;
283
- const nodeIndex = Array.from(parent.childNodes).indexOf(node);
284
- for (
285
- let index = toBackward ? 0 : nodeIndex + 1;
286
- index < (toBackward ? nodeIndex : parent.childNodes.length);
287
- index++
288
- ) {
289
- applyStyleToChildNode(fontSizeStyle, parent, index);
290
- }
291
- applyStyleToSiblings(fontSizeStyle, top, parent, toBackward);
292
- }
293
-
294
- function applyStyleForward(fontSizeStyle, top, node) {
295
- applyStyleToSiblings(fontSizeStyle, top, node);
296
- }
297
-
298
- function applyStyleBackward(fontSizeStyle, top, node) {
299
- applyStyleToSiblings(fontSizeStyle, top, node, true);
300
- }
301
-
302
- const fontSizeStyle = `${fontSize}px`;
303
-
304
- const selection = window.getSelection();
305
- const range = selection.getRangeAt(0);
306
- if (range.collapsed) {
307
- const emptyNode = document.createElement('span');
308
- emptyNode.style.fontSize = fontSizeStyle;
309
- range.insertNode(emptyNode);
310
- return;
311
- }
312
-
313
- const topNode = range.commonAncestorContainer;
314
- const start = range.startContainer;
315
- const end = range.endContainer;
316
-
317
- const isSingleNode = start === end;
318
- const isNotFromNodeStart = range.startOffset > 0;
319
- const isNotToNodeEnd = range.endOffset < end.length;
320
- const isPartialSelection = isNotFromNodeStart || isNotToNodeEnd;
321
- const isRootChildren = isRootNode(topNode);
322
-
323
- if (isSingleNode) {
324
- if (isPartialSelection || isRootChildren) {
325
- const wrapperNode = wrapAndReplaceRange(range);
326
- wrapperNode.style.fontSize = fontSizeStyle;
327
- } else {
328
- const targetNode =
329
- topNode.nodeType === Node.TEXT_NODE ? wrapAndReplaceTextNodeRange(topNode) : topNode;
330
- targetNode.style.fontSize = fontSizeStyle;
331
- selection.extend(targetNode.childNodes[0], targetNode.textContent.length);
332
- }
333
- } else {
334
- const startNode = getDirectChildNode(topNode, start);
335
- const endNode = getDirectChildNode(topNode, end);
336
- const startIndex = Array.from(topNode.childNodes).indexOf(startNode);
337
- const endIndex = Array.from(topNode.childNodes).indexOf(endNode);
338
- // middle line(s)
339
- for (let index = startIndex + 1; index < endIndex; index++) {
340
- applyStyleToChildNode(fontSizeStyle, topNode, index);
341
- }
342
- // first line
343
- if (isNotFromNodeStart) {
344
- const startRange = document.createRange();
345
- startRange.setStart(start, range.startOffset);
346
- startRange.setEnd(start, start.length);
347
- const startWrapper = wrapAndReplaceRange(startRange);
348
- startWrapper.style.fontSize = fontSizeStyle;
349
- // range.setStart(startWrapper, 0);
350
- // selection.collapse(startWrapper);
351
- // selection.extend(end, range.endOffset);
352
- applyStyleForward(fontSizeStyle, topNode, startWrapper);
353
- } else {
354
- const firstNode =
355
- start.nodeType === Node.TEXT_NODE ? getNonTextNode(topNode, start) : start;
356
- firstNode.style.fontSize = fontSizeStyle;
357
- if (start !== startNode) applyStyleForward(fontSizeStyle, topNode, firstNode);
358
- }
359
- // last line
360
- if (isNotToNodeEnd) {
361
- const endRange = document.createRange();
362
- endRange.setStart(end, 0);
363
- endRange.setEnd(end, range.endOffset);
364
- const endWrapper = wrapAndReplaceRange(endRange);
365
- endWrapper.style.fontSize = fontSizeStyle;
366
- range.setEnd(endWrapper.childNodes[0], endWrapper.textContent.length);
367
- selection.extend(endWrapper.childNodes[0], endWrapper.textContent.length);
368
- applyStyleBackward(fontSizeStyle, topNode, endWrapper);
369
- } else {
370
- const lastNode = end.nodeType === Node.TEXT_NODE ? getNonTextNode(topNode, end) : end;
371
- lastNode.style.fontSize = fontSizeStyle;
372
- if (end !== endNode) applyStyleBackward(fontSizeStyle, topNode, lastNode);
373
- }
374
- }
375
-
376
- // FIXME: exeCommand API 를 호출하지 않으면 Quasar Editor 컴포넌트에서 모델 업데이트가 일어나지 않음.
377
- // 강제로 bold 활성화 비활성화 동작 수행으로 모델 변경 적용. 원인 파악 후 적절히 해결할 것
378
- // 참고> execCommand 만으로 스타일 적용 가능하지만, div 태그로 감싸지지 않은 첫 줄에서 수행 시 태그 위치 설정 관련 오류 발생
379
- //
380
- // const spanString = `<span style="font-size: ${fontSize}px;">${document
381
- // .getSelection()
382
- // .toString()}</span>`;
383
- // document.execCommand('insertHTML', false, spanString);
384
- //
385
- document.execCommand('bold', false);
386
- document.execCommand('bold', false);
387
- }
388
-
389
- const sizeDefinitions = extend(
390
- true,
391
- {
392
- size1: {
393
- label: '9px',
394
- size: 9,
395
- },
396
- size2: {
397
- label: '11px',
398
- size: 11,
399
- },
400
- size3: {
401
- label: '13px',
402
- size: 13,
403
- },
404
- size4: {
405
- label: '15px',
406
- size: 15,
407
- },
408
- size5: {
409
- label: '18px',
410
- size: 18,
411
- },
412
- size6: {
413
- label: '20px',
414
- size: 20,
415
- },
416
- adjustLabelSize: true,
417
- },
418
- props.fontSizes || {},
419
- );
420
-
421
- const toolDefinitions = extend(true, {}, props.definitions || {}, {
422
- // upload: {
423
- // icon: 'image',
424
- // handler: () => isOpened.value = true,
425
- // },
426
- size1: {
427
- tip: sizeDefinitions.size1.label,
428
- htmlTip: sizeDefinitions.adjustLabelSize
429
- ? `<span style="font-size: ${sizeDefinitions.size1.size}px">${sizeDefinitions.size1.label}</span>`
430
- : null,
431
- handler: () => setFontSize(sizeDefinitions.size1.size),
432
- },
433
- size2: {
434
- tip: sizeDefinitions.size2.label,
435
- htmlTip: sizeDefinitions.adjustLabelSize
436
- ? `<span style="font-size: ${sizeDefinitions.size2.size}px">${sizeDefinitions.size2.label}</span>`
437
- : null,
438
- handler: () => setFontSize(sizeDefinitions.size2.size),
439
- },
440
- size3: {
441
- tip: sizeDefinitions.size3.label,
442
- htmlTip: sizeDefinitions.adjustLabelSize
443
- ? `<span style="font-size: ${sizeDefinitions.size3.size}px">${sizeDefinitions.size3.label}</span>`
444
- : null,
445
- handler: () => setFontSize(sizeDefinitions.size3.size),
446
- },
447
- size4: {
448
- tip: sizeDefinitions.size4.label,
449
- htmlTip: sizeDefinitions.adjustLabelSize
450
- ? `<span style="font-size: ${sizeDefinitions.size4.size}px">${sizeDefinitions.size4.label}</span>`
451
- : null,
452
- handler: () => setFontSize(sizeDefinitions.size4.size),
453
- },
454
- size5: {
455
- tip: sizeDefinitions.size5.label,
456
- htmlTip: sizeDefinitions.adjustLabelSize
457
- ? `<span style="font-size: ${sizeDefinitions.size5.size}px">${sizeDefinitions.size5.label}</span>`
458
- : null,
459
- handler: () => setFontSize(sizeDefinitions.size5.size),
460
- },
461
- size6: {
462
- tip: sizeDefinitions.size6.label,
463
- htmlTip: sizeDefinitions.adjustLabelSize
464
- ? `<span style="font-size: ${sizeDefinitions.size6.size}px">${sizeDefinitions.size6.label}</span>`
465
- : null,
466
- handler: () => setFontSize(sizeDefinitions.size6.size),
467
- },
468
- });
469
-
470
- const fontSizes = {
471
- label: $q.lang.editor.fontSize,
472
- icon: $q.iconSet.editor.fontSize,
473
- fixedLabel: true,
474
- fixedIcon: true,
475
- list: 'no-icons',
476
- options: ['size1', 'size2', 'size3', 'size4', 'size5', 'size6'],
477
- };
478
-
479
- function onOkClick() {
480
- imgValue.value.split('\n').forEach((el, idx) => {
481
- if (el === '') return;
482
- model.value += `<p style="max-width: 100%"><img style="max-width: 100%" src=${el} :alt="image${idx}" /></p>`;
483
- });
484
- if (props.imgResizable) addImageClickListener();
485
- imgValue.value = '';
486
- }
487
- // Toolbar End
488
-
489
- watch(
490
- () => props.modelValue,
491
- newValue => {
492
- model.value = newValue;
493
- },
494
- );
495
- watch(
496
- () => model.value,
497
- newValue => {
498
- emit('update:modelValue', newValue);
499
- },
500
- { deep: true },
501
- );
502
-
503
- // https://quasar.dev/vue-components/editor#plaintext-pasting 참조
504
- function onPaste(event) {
505
- if (event.target.nodeName === 'INPUT') return;
506
- let text = '';
507
- let onPasteStripFormattingIEPaste = false;
508
- event.preventDefault();
509
- event.stopPropagation();
510
- if (event.originalEvent && event.originalEvent.clipboardData.getData) {
511
- text = event.originalEvent.clipboardData.getData('text/plain');
512
- editorRef.value.runCmd('insertText', text);
513
- } else if (event.clipboardData && event.clipboardData.getData) {
514
- text = event.clipboardData.getData('text/plain');
515
- editorRef.value.runCmd('insertText', text);
516
- } else if (window.clipboardData && window.clipboardData.getData) {
517
- if (!onPasteStripFormattingIEPaste) {
518
- onPasteStripFormattingIEPaste = true;
519
- editorRef.value.runCmd('ms-pasteTextOnly', text);
520
- }
521
- onPasteStripFormattingIEPaste = false;
522
- }
523
- }
524
-
525
- return {
526
- toolbarDefinitions: computed(() => {
527
- const bar = props.useImgUpload ? [...props.toolbar, ['upload']] : props.toolbar;
528
- return bar.map(group => group.map(item => (item === 'fontSizes' ? fontSizes : item)));
529
- }),
530
- toolDefinitions,
531
- model,
532
- imgValue,
533
- onOkClick,
534
- onPaste,
535
- editorRef,
536
- };
537
- },
538
- });
539
- </script>
540
- <style lang="scss">
541
- .s-editor {
542
- > .q-editor__toolbars-container {
543
- > .q-editor__toolbar {
544
- height: 32px;
545
- overflow-y: hidden;
546
- }
547
- }
548
-
549
- // 툴바 순서 변경시 참고 필요
550
- .q-editor__toolbars-container {
551
- > .q-editor__toolbar {
552
- > .q-editor__toolbar-group:nth-child(2) {
553
- > .q-btn-dropdown {
554
- > .q-btn__content {
555
- width: 91px;
556
- }
557
- }
558
- }
559
- }
560
- }
561
- .q-editor__content {
562
- > p > img {
563
- display: inline-block;
564
- }
565
- .img-resize-handler {
566
- position: relative;
567
- background-color: transparent;
568
- display: inline-block;
569
- resize: both;
570
- overflow: hidden;
571
- &:after {
572
- content: '';
573
- position: absolute;
574
- border: 1px solid;
575
- width: 100%;
576
- height: 100%;
577
- top: 0;
578
- left: 0;
579
- }
580
-
581
- > img {
582
- display: inline-block;
583
- object-fit: contain;
584
- width: 100%;
585
- height: 100%;
586
- }
587
- }
588
- }
589
- }
590
- </style>
1
+ <template>
2
+ <q-editor
3
+ ref="editorRef"
4
+ :content-style="{
5
+ 'font-size': `${defaultFontSize}px`,
6
+ resize: resizable ? 'vertical' : 'none',
7
+ }"
8
+ :min-height="`${minHeight}px`"
9
+ :max-height="maxHeight != null ? `${maxHeight}px` : null"
10
+ paragraph-tag="p"
11
+ v-bind="$attrs"
12
+ :definitions="toolDefinitions"
13
+ :toolbar="toolbarDefinitions"
14
+ v-model="model"
15
+ class="s-editor"
16
+ v-on="usePlaintextPasting ? { paste: onPaste } : {}"
17
+ >
18
+ <template #upload>
19
+ <q-btn flat dense :ripple="false" size="sm">
20
+ <q-icon name="image" />
21
+ <q-menu>
22
+ <q-card class="q-pa-sm bg-Grey_Lighten-5">
23
+ <q-card-section class="q-pa-none">
24
+ <q-input
25
+ class="bg-white font-12-400"
26
+ style="width: 352px"
27
+ v-model="imgValue"
28
+ dense
29
+ outlined
30
+ type="textarea"
31
+ :placeholder="imgInputPlaceholder"
32
+ autofocus
33
+ />
34
+ <div class="row reverse q-mt-sm">
35
+ <s-button
36
+ outline
37
+ :label="imgUploadBtnLabel"
38
+ @click="onOkClick"
39
+ v-close-popup
40
+ color="Blue_B_Default"
41
+ class="bg-white"
42
+ />
43
+ </div>
44
+ </q-card-section>
45
+ </q-card>
46
+ </q-menu>
47
+ </q-btn>
48
+ </template>
49
+ </q-editor>
50
+ </template>
51
+
52
+ <script>
53
+ import { defineComponent, ref, watch, computed, onMounted } from 'vue';
54
+ import {
55
+ extend,
56
+ QCard,
57
+ QCardSection,
58
+ QMenu,
59
+ QIcon,
60
+ QEditor,
61
+ QInput,
62
+ QBtn,
63
+ useQuasar,
64
+ } from 'quasar';
65
+ import resizeImage from '../composables/resizable';
66
+
67
+ export default defineComponent({
68
+ name: 'SEditor',
69
+ components: {
70
+ QEditor,
71
+ QMenu,
72
+ QCard,
73
+ QCardSection,
74
+ QInput,
75
+ QBtn,
76
+ QIcon,
77
+ },
78
+ props: {
79
+ definitions: Object,
80
+ toolbar: {
81
+ type: Array,
82
+ validator: v => v.length === 0 || v.every(group => group.length),
83
+ default: () => [
84
+ ['left', 'center', 'right', 'justify'],
85
+ ['bold', 'italic', 'underline', 'strike'],
86
+ ['link', 'hr'],
87
+ ['fontSizes', 'removeFormat'],
88
+ ['unordered', 'ordered', 'outdent', 'indent'],
89
+ ['undo', 'redo'],
90
+ ['viewsource'],
91
+ ],
92
+ },
93
+ defaultFontSize: {
94
+ type: Number,
95
+ default: 13,
96
+ },
97
+ fontSizes: {
98
+ type: Object,
99
+ default: null,
100
+ },
101
+ resizable: {
102
+ type: Boolean,
103
+ default: false,
104
+ },
105
+ minHeight: {
106
+ type: Number,
107
+ default: 250,
108
+ },
109
+ maxHeight: {
110
+ type: Number,
111
+ default: null,
112
+ },
113
+ modelValue: {
114
+ type: String,
115
+ required: true,
116
+ },
117
+ useImgUpload: {
118
+ type: Boolean,
119
+ default: false,
120
+ },
121
+ imgUploadBtnLabel: {
122
+ type: String,
123
+ default: '삽입',
124
+ },
125
+ imgInputPlaceholder: {
126
+ type: String,
127
+ default:
128
+ '이미지 파일 경로를 엔터키로 구분하여 여러개 입력할 수 있습니다.\n예시)\nhttps://www.sellmate.co.kr/img/new/logo01.png\nhttps://www.sellmate.co.kr/img/new/logo02.png\nhttps://www.sellmate.co.kr/img/new/logo03.png',
129
+ },
130
+ usePlaintextPasting: {
131
+ type: Boolean,
132
+ default: false,
133
+ },
134
+ imgResizable: {
135
+ type: Boolean,
136
+ default: false,
137
+ },
138
+ },
139
+ setup(props, { emit }) {
140
+ const model = ref(props.modelValue);
141
+ const editorRef = ref(null);
142
+
143
+ // delete handler element
144
+ function unwrapImg() {
145
+ const contentEl = editorRef.value.getContentEl();
146
+ const resizeHandler = contentEl.querySelectorAll('.img-resize-handler');
147
+
148
+ resizeHandler.forEach(div => {
149
+ const img = div.querySelector('img');
150
+ const parentParagraph = div.parentNode;
151
+ parentParagraph.replaceChild(img, div);
152
+ });
153
+
154
+ model.value = contentEl.innerHTML;
155
+ }
156
+
157
+ // create handler element & resize event
158
+ function wrapImgWithDiv(targetImg) {
159
+ const newDiv = document.createElement('div');
160
+ const className = 'img-resize-handler';
161
+ newDiv.classList.add(className);
162
+ newDiv.style.height = `${targetImg.height}px`;
163
+ newDiv.style.width = `${targetImg.width}px`;
164
+ targetImg.parentNode.insertBefore(newDiv, targetImg);
165
+ newDiv.append(targetImg);
166
+ resizeImage(className);
167
+ }
168
+
169
+ // add click event in contents
170
+ function addImageClickListener() {
171
+ if (!editorRef.value) {
172
+ return;
173
+ }
174
+ const contentEl = editorRef.value.getContentEl();
175
+
176
+ if (!contentEl) {
177
+ return;
178
+ }
179
+
180
+ contentEl.addEventListener('click', event => {
181
+ const images = contentEl.querySelectorAll('img');
182
+ if (!images.length) {
183
+ return;
184
+ }
185
+
186
+ // when clicked, check image document in contents
187
+ if (Array.from(images).some(el => el.contains(event.target))) {
188
+ wrapImgWithDiv(event.target);
189
+ return;
190
+ }
191
+
192
+ unwrapImg();
193
+ });
194
+ }
195
+
196
+ onMounted(() => {
197
+ if (!props.imgResizable) return;
198
+ const contentEl = editorRef.value.getContentEl();
199
+ if (contentEl) {
200
+ addImageClickListener();
201
+ }
202
+ });
203
+
204
+ const $q = useQuasar();
205
+
206
+ // Toolbar Begin
207
+ const imgValue = ref('');
208
+
209
+ function setFontSize(fontSize) {
210
+ function isRootNode(node) {
211
+ return (
212
+ node.nodeType !== Node.TEXT_NODE &&
213
+ (node.className.indexOf('q-editor__content') >= 0 ||
214
+ node.hasAttribute('contenteditable'))
215
+ );
216
+ }
217
+
218
+ function wrapAndReplaceRange(range) {
219
+ const content = document.createTextNode(range.toString());
220
+ const wrapperNode = document.createElement('span');
221
+ wrapperNode.append(content);
222
+
223
+ range.deleteContents();
224
+ range.insertNode(wrapperNode);
225
+
226
+ return wrapperNode;
227
+ }
228
+
229
+ function wrapAndReplaceTextNodeRange(node) {
230
+ const nodeRange = document.createRange();
231
+ nodeRange.setStart(node, 0);
232
+ nodeRange.setEnd(node, node.length);
233
+ return wrapAndReplaceRange(nodeRange);
234
+ }
235
+
236
+ function wrapTextNode(node) {
237
+ const wrapper = document.createElement('span');
238
+ wrapper.innerText = node.textContent;
239
+ return wrapper;
240
+ }
241
+
242
+ function getNonTextNode(top, node) {
243
+ const parent = node.parentNode;
244
+ if (
245
+ top !== parent &&
246
+ Array.from(parent.childNodes).length === 1 &&
247
+ parent.childNodes[0] === node
248
+ ) {
249
+ return parent;
250
+ }
251
+ return wrapAndReplaceTextNodeRange(node);
252
+ }
253
+
254
+ function getDirectChildNode(parent, node) {
255
+ if (node.parentNode === parent) {
256
+ return node;
257
+ }
258
+ return getDirectChildNode(parent, node.parentNode);
259
+ }
260
+
261
+ function removeChildrenFontStyle(node) {
262
+ node.childNodes.forEach(child => {
263
+ if (child.nodeType !== Node.TEXT_NODE) {
264
+ child.style.fontSize = null;
265
+ removeChildrenFontStyle(child);
266
+ }
267
+ });
268
+ }
269
+
270
+ function applyStyleToChildNode(fontSizeStyle, node, childIndex) {
271
+ const childNode = node.childNodes[childIndex];
272
+ if (childNode.nodeType === Node.TEXT_NODE) {
273
+ const nodeWrapper = wrapTextNode(childNode);
274
+ node.replaceChild(nodeWrapper, childNode);
275
+ }
276
+ node.childNodes[childIndex].style.fontSize = fontSizeStyle;
277
+ removeChildrenFontStyle(node.childNodes[childIndex]);
278
+ }
279
+
280
+ function applyStyleToSiblings(fontSizeStyle, top, node, toBackward = false) {
281
+ const parent = node.parentNode;
282
+ if (top === parent) return;
283
+ const nodeIndex = Array.from(parent.childNodes).indexOf(node);
284
+ for (
285
+ let index = toBackward ? 0 : nodeIndex + 1;
286
+ index < (toBackward ? nodeIndex : parent.childNodes.length);
287
+ index++
288
+ ) {
289
+ applyStyleToChildNode(fontSizeStyle, parent, index);
290
+ }
291
+ applyStyleToSiblings(fontSizeStyle, top, parent, toBackward);
292
+ }
293
+
294
+ function applyStyleForward(fontSizeStyle, top, node) {
295
+ applyStyleToSiblings(fontSizeStyle, top, node);
296
+ }
297
+
298
+ function applyStyleBackward(fontSizeStyle, top, node) {
299
+ applyStyleToSiblings(fontSizeStyle, top, node, true);
300
+ }
301
+
302
+ const fontSizeStyle = `${fontSize}px`;
303
+
304
+ const selection = window.getSelection();
305
+ const range = selection.getRangeAt(0);
306
+ if (range.collapsed) {
307
+ const emptyNode = document.createElement('span');
308
+ emptyNode.style.fontSize = fontSizeStyle;
309
+ range.insertNode(emptyNode);
310
+ return;
311
+ }
312
+
313
+ const topNode = range.commonAncestorContainer;
314
+ const start = range.startContainer;
315
+ const end = range.endContainer;
316
+
317
+ const isSingleNode = start === end;
318
+ const isNotFromNodeStart = range.startOffset > 0;
319
+ const isNotToNodeEnd = range.endOffset < end.length;
320
+ const isPartialSelection = isNotFromNodeStart || isNotToNodeEnd;
321
+ const isRootChildren = isRootNode(topNode);
322
+
323
+ if (isSingleNode) {
324
+ if (isPartialSelection || isRootChildren) {
325
+ const wrapperNode = wrapAndReplaceRange(range);
326
+ wrapperNode.style.fontSize = fontSizeStyle;
327
+ } else {
328
+ const targetNode =
329
+ topNode.nodeType === Node.TEXT_NODE ? wrapAndReplaceTextNodeRange(topNode) : topNode;
330
+ targetNode.style.fontSize = fontSizeStyle;
331
+ selection.extend(targetNode.childNodes[0], targetNode.textContent.length);
332
+ }
333
+ } else {
334
+ const startNode = getDirectChildNode(topNode, start);
335
+ const endNode = getDirectChildNode(topNode, end);
336
+ const startIndex = Array.from(topNode.childNodes).indexOf(startNode);
337
+ const endIndex = Array.from(topNode.childNodes).indexOf(endNode);
338
+ // middle line(s)
339
+ for (let index = startIndex + 1; index < endIndex; index++) {
340
+ applyStyleToChildNode(fontSizeStyle, topNode, index);
341
+ }
342
+ // first line
343
+ if (isNotFromNodeStart) {
344
+ const startRange = document.createRange();
345
+ startRange.setStart(start, range.startOffset);
346
+ startRange.setEnd(start, start.length);
347
+ const startWrapper = wrapAndReplaceRange(startRange);
348
+ startWrapper.style.fontSize = fontSizeStyle;
349
+ // range.setStart(startWrapper, 0);
350
+ // selection.collapse(startWrapper);
351
+ // selection.extend(end, range.endOffset);
352
+ applyStyleForward(fontSizeStyle, topNode, startWrapper);
353
+ } else {
354
+ const firstNode =
355
+ start.nodeType === Node.TEXT_NODE ? getNonTextNode(topNode, start) : start;
356
+ firstNode.style.fontSize = fontSizeStyle;
357
+ if (start !== startNode) applyStyleForward(fontSizeStyle, topNode, firstNode);
358
+ }
359
+ // last line
360
+ if (isNotToNodeEnd) {
361
+ const endRange = document.createRange();
362
+ endRange.setStart(end, 0);
363
+ endRange.setEnd(end, range.endOffset);
364
+ const endWrapper = wrapAndReplaceRange(endRange);
365
+ endWrapper.style.fontSize = fontSizeStyle;
366
+ range.setEnd(endWrapper.childNodes[0], endWrapper.textContent.length);
367
+ selection.extend(endWrapper.childNodes[0], endWrapper.textContent.length);
368
+ applyStyleBackward(fontSizeStyle, topNode, endWrapper);
369
+ } else {
370
+ const lastNode = end.nodeType === Node.TEXT_NODE ? getNonTextNode(topNode, end) : end;
371
+ lastNode.style.fontSize = fontSizeStyle;
372
+ if (end !== endNode) applyStyleBackward(fontSizeStyle, topNode, lastNode);
373
+ }
374
+ }
375
+
376
+ // FIXME: exeCommand API 를 호출하지 않으면 Quasar Editor 컴포넌트에서 모델 업데이트가 일어나지 않음.
377
+ // 강제로 bold 활성화 비활성화 동작 수행으로 모델 변경 적용. 원인 파악 후 적절히 해결할 것
378
+ // 참고> execCommand 만으로 스타일 적용 가능하지만, div 태그로 감싸지지 않은 첫 줄에서 수행 시 태그 위치 설정 관련 오류 발생
379
+ //
380
+ // const spanString = `<span style="font-size: ${fontSize}px;">${document
381
+ // .getSelection()
382
+ // .toString()}</span>`;
383
+ // document.execCommand('insertHTML', false, spanString);
384
+ //
385
+ document.execCommand('bold', false);
386
+ document.execCommand('bold', false);
387
+ }
388
+
389
+ const sizeDefinitions = extend(
390
+ true,
391
+ {
392
+ size1: {
393
+ label: '9px',
394
+ size: 9,
395
+ },
396
+ size2: {
397
+ label: '11px',
398
+ size: 11,
399
+ },
400
+ size3: {
401
+ label: '13px',
402
+ size: 13,
403
+ },
404
+ size4: {
405
+ label: '15px',
406
+ size: 15,
407
+ },
408
+ size5: {
409
+ label: '18px',
410
+ size: 18,
411
+ },
412
+ size6: {
413
+ label: '20px',
414
+ size: 20,
415
+ },
416
+ adjustLabelSize: true,
417
+ },
418
+ props.fontSizes || {},
419
+ );
420
+
421
+ const toolDefinitions = extend(true, {}, props.definitions || {}, {
422
+ // upload: {
423
+ // icon: 'image',
424
+ // handler: () => isOpened.value = true,
425
+ // },
426
+ size1: {
427
+ tip: sizeDefinitions.size1.label,
428
+ htmlTip: sizeDefinitions.adjustLabelSize
429
+ ? `<span style="font-size: ${sizeDefinitions.size1.size}px">${sizeDefinitions.size1.label}</span>`
430
+ : null,
431
+ handler: () => setFontSize(sizeDefinitions.size1.size),
432
+ },
433
+ size2: {
434
+ tip: sizeDefinitions.size2.label,
435
+ htmlTip: sizeDefinitions.adjustLabelSize
436
+ ? `<span style="font-size: ${sizeDefinitions.size2.size}px">${sizeDefinitions.size2.label}</span>`
437
+ : null,
438
+ handler: () => setFontSize(sizeDefinitions.size2.size),
439
+ },
440
+ size3: {
441
+ tip: sizeDefinitions.size3.label,
442
+ htmlTip: sizeDefinitions.adjustLabelSize
443
+ ? `<span style="font-size: ${sizeDefinitions.size3.size}px">${sizeDefinitions.size3.label}</span>`
444
+ : null,
445
+ handler: () => setFontSize(sizeDefinitions.size3.size),
446
+ },
447
+ size4: {
448
+ tip: sizeDefinitions.size4.label,
449
+ htmlTip: sizeDefinitions.adjustLabelSize
450
+ ? `<span style="font-size: ${sizeDefinitions.size4.size}px">${sizeDefinitions.size4.label}</span>`
451
+ : null,
452
+ handler: () => setFontSize(sizeDefinitions.size4.size),
453
+ },
454
+ size5: {
455
+ tip: sizeDefinitions.size5.label,
456
+ htmlTip: sizeDefinitions.adjustLabelSize
457
+ ? `<span style="font-size: ${sizeDefinitions.size5.size}px">${sizeDefinitions.size5.label}</span>`
458
+ : null,
459
+ handler: () => setFontSize(sizeDefinitions.size5.size),
460
+ },
461
+ size6: {
462
+ tip: sizeDefinitions.size6.label,
463
+ htmlTip: sizeDefinitions.adjustLabelSize
464
+ ? `<span style="font-size: ${sizeDefinitions.size6.size}px">${sizeDefinitions.size6.label}</span>`
465
+ : null,
466
+ handler: () => setFontSize(sizeDefinitions.size6.size),
467
+ },
468
+ });
469
+
470
+ const fontSizes = {
471
+ label: $q.lang.editor.fontSize,
472
+ icon: $q.iconSet.editor.fontSize,
473
+ fixedLabel: true,
474
+ fixedIcon: true,
475
+ list: 'no-icons',
476
+ options: ['size1', 'size2', 'size3', 'size4', 'size5', 'size6'],
477
+ };
478
+
479
+ function onOkClick() {
480
+ imgValue.value.split('\n').forEach((el, idx) => {
481
+ if (el === '') return;
482
+ model.value += `<p style="max-width: 100%"><img style="max-width: 100%" src=${el} :alt="image${idx}" /></p>`;
483
+ });
484
+ if (props.imgResizable) addImageClickListener();
485
+ imgValue.value = '';
486
+ }
487
+ // Toolbar End
488
+
489
+ watch(
490
+ () => props.modelValue,
491
+ newValue => {
492
+ model.value = newValue;
493
+ },
494
+ );
495
+ watch(
496
+ () => model.value,
497
+ newValue => {
498
+ emit('update:modelValue', newValue);
499
+ },
500
+ { deep: true },
501
+ );
502
+
503
+ // https://quasar.dev/vue-components/editor#plaintext-pasting 참조
504
+ function onPaste(event) {
505
+ if (event.target.nodeName === 'INPUT') return;
506
+ let text = '';
507
+ let onPasteStripFormattingIEPaste = false;
508
+ event.preventDefault();
509
+ event.stopPropagation();
510
+ if (event.originalEvent && event.originalEvent.clipboardData.getData) {
511
+ text = event.originalEvent.clipboardData.getData('text/plain');
512
+ editorRef.value.runCmd('insertText', text);
513
+ } else if (event.clipboardData && event.clipboardData.getData) {
514
+ text = event.clipboardData.getData('text/plain');
515
+ editorRef.value.runCmd('insertText', text);
516
+ } else if (window.clipboardData && window.clipboardData.getData) {
517
+ if (!onPasteStripFormattingIEPaste) {
518
+ onPasteStripFormattingIEPaste = true;
519
+ editorRef.value.runCmd('ms-pasteTextOnly', text);
520
+ }
521
+ onPasteStripFormattingIEPaste = false;
522
+ }
523
+ }
524
+
525
+ return {
526
+ toolbarDefinitions: computed(() => {
527
+ const bar = props.useImgUpload ? [...props.toolbar, ['upload']] : props.toolbar;
528
+ return bar.map(group => group.map(item => (item === 'fontSizes' ? fontSizes : item)));
529
+ }),
530
+ toolDefinitions,
531
+ model,
532
+ imgValue,
533
+ onOkClick,
534
+ onPaste,
535
+ editorRef,
536
+ };
537
+ },
538
+ });
539
+ </script>
540
+ <style lang="scss">
541
+ .s-editor {
542
+ > .q-editor__toolbars-container {
543
+ > .q-editor__toolbar {
544
+ height: 32px;
545
+ overflow-y: hidden;
546
+ }
547
+ }
548
+
549
+ // 툴바 순서 변경시 참고 필요
550
+ .q-editor__toolbars-container {
551
+ > .q-editor__toolbar {
552
+ > .q-editor__toolbar-group:nth-child(2) {
553
+ > .q-btn-dropdown {
554
+ > .q-btn__content {
555
+ width: 91px;
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+ .q-editor__content {
562
+ > p > img {
563
+ display: inline-block;
564
+ }
565
+ .img-resize-handler {
566
+ position: relative;
567
+ background-color: transparent;
568
+ display: inline-block;
569
+ resize: both;
570
+ overflow: hidden;
571
+ &:after {
572
+ content: '';
573
+ position: absolute;
574
+ border: 1px solid;
575
+ width: 100%;
576
+ height: 100%;
577
+ top: 0;
578
+ left: 0;
579
+ }
580
+
581
+ > img {
582
+ display: inline-block;
583
+ object-fit: contain;
584
+ width: 100%;
585
+ height: 100%;
586
+ }
587
+ }
588
+ }
589
+ }
590
+ </style>