@commonpub/editor 0.7.1 → 0.7.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.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonpub/editor",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "TipTap block editor with 18+ maker-focused extensions for CommonPub",
|
|
6
6
|
"license": "AGPL-3.0-or-later",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"remark-rehype": "^11.1.2",
|
|
51
51
|
"unified": "^11.0.5",
|
|
52
52
|
"zod": "^4.3.6",
|
|
53
|
-
"@commonpub/
|
|
54
|
-
"@commonpub/
|
|
53
|
+
"@commonpub/schema": "0.9.4",
|
|
54
|
+
"@commonpub/config": "0.9.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"vue": "^3.4.0",
|
|
@@ -140,7 +140,8 @@ const floatingToolbar = ref<{
|
|
|
140
140
|
top: number;
|
|
141
141
|
left: number;
|
|
142
142
|
blockId: string;
|
|
143
|
-
|
|
143
|
+
activeMarks: { bold: boolean; italic: boolean; strike: boolean; code: boolean; link: boolean };
|
|
144
|
+
}>({ visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } });
|
|
144
145
|
|
|
145
146
|
function onSelectionChange(block: EditorBlock, hasSelection: boolean, rect: DOMRect | null): void {
|
|
146
147
|
if (hasSelection && rect) {
|
|
@@ -148,14 +149,24 @@ function onSelectionChange(block: EditorBlock, hasSelection: boolean, rect: DOMR
|
|
|
148
149
|
const toolbarHeight = 44;
|
|
149
150
|
const rawTop = rect.top - toolbarHeight;
|
|
150
151
|
const rawLeft = rect.left + rect.width / 2;
|
|
152
|
+
// Query active marks from the TipTap editor
|
|
153
|
+
const editor = getActiveEditorForBlock(block.id);
|
|
154
|
+
const marks = {
|
|
155
|
+
bold: editor?.isActive('bold') ?? false,
|
|
156
|
+
italic: editor?.isActive('italic') ?? false,
|
|
157
|
+
strike: editor?.isActive('strike') ?? false,
|
|
158
|
+
code: editor?.isActive('code') ?? false,
|
|
159
|
+
link: editor?.isActive('link') ?? false,
|
|
160
|
+
};
|
|
151
161
|
floatingToolbar.value = {
|
|
152
162
|
visible: true,
|
|
153
163
|
top: Math.max(4, rawTop),
|
|
154
164
|
left: Math.max(toolbarWidth / 2 + 4, Math.min(rawLeft, window.innerWidth - toolbarWidth / 2 - 4)),
|
|
155
165
|
blockId: block.id,
|
|
166
|
+
activeMarks: marks,
|
|
156
167
|
};
|
|
157
168
|
} else {
|
|
158
|
-
floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
|
|
169
|
+
floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } };
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
|
|
@@ -168,6 +179,11 @@ function setBlockRef(blockId: string, el: unknown): void {
|
|
|
168
179
|
}
|
|
169
180
|
}
|
|
170
181
|
|
|
182
|
+
function getActiveEditorForBlock(blockId: string): TipTapEditor | null {
|
|
183
|
+
const ref = blockRefs.value.get(blockId);
|
|
184
|
+
return (ref?.getEditor?.() as TipTapEditor) ?? null;
|
|
185
|
+
}
|
|
186
|
+
|
|
171
187
|
function getActiveEditor(): unknown {
|
|
172
188
|
const ref = blockRefs.value.get(floatingToolbar.value.blockId);
|
|
173
189
|
return ref?.getEditor?.() ?? null;
|
|
@@ -193,6 +209,9 @@ function toggleMark(mark: string): void {
|
|
|
193
209
|
editor.chain().focus().toggleMark(mark).run();
|
|
194
210
|
}
|
|
195
211
|
|
|
212
|
+
// --- Link URL inline prompt ---
|
|
213
|
+
const linkPrompt = ref<{ visible: boolean; url: string }>({ visible: false, url: '' });
|
|
214
|
+
|
|
196
215
|
function toggleLink(): void {
|
|
197
216
|
const editor = getActiveEditor() as TipTapEditor | null;
|
|
198
217
|
if (!editor) return;
|
|
@@ -200,10 +219,23 @@ function toggleLink(): void {
|
|
|
200
219
|
editor.chain().focus().unsetLink().run();
|
|
201
220
|
return;
|
|
202
221
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
222
|
+
// Open inline prompt instead of window.prompt
|
|
223
|
+
linkPrompt.value = { visible: true, url: '' };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function applyLink(): void {
|
|
227
|
+
const url = linkPrompt.value.url.trim();
|
|
228
|
+
linkPrompt.value = { visible: false, url: '' };
|
|
229
|
+
if (!url) return;
|
|
230
|
+
const editor = getActiveEditor() as TipTapEditor | null;
|
|
231
|
+
if (!editor) return;
|
|
232
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function cancelLink(): void {
|
|
236
|
+
linkPrompt.value = { visible: false, url: '' };
|
|
237
|
+
const editor = getActiveEditor() as TipTapEditor | null;
|
|
238
|
+
editor?.chain().focus().run();
|
|
207
239
|
}
|
|
208
240
|
|
|
209
241
|
// --- Empty state ---
|
|
@@ -275,7 +307,7 @@ function onDrop(atIndex: number, event: DragEvent): void {
|
|
|
275
307
|
// --- Click outside to deselect ---
|
|
276
308
|
function onCanvasClick(): void {
|
|
277
309
|
props.blockEditor.selectBlock(null);
|
|
278
|
-
floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
|
|
310
|
+
floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } };
|
|
279
311
|
}
|
|
280
312
|
|
|
281
313
|
// --- Resolve block component (injected overrides take priority) ---
|
|
@@ -310,24 +342,51 @@ function needsSearch(type: string): boolean {
|
|
|
310
342
|
return type === 'partsList';
|
|
311
343
|
}
|
|
312
344
|
|
|
313
|
-
// ---
|
|
345
|
+
// --- Keyboard shortcuts ---
|
|
314
346
|
function onKeydown(event: KeyboardEvent): void {
|
|
315
347
|
const mod = event.metaKey || event.ctrlKey;
|
|
316
|
-
if (!mod || event.key.toLowerCase() !== 'z') return;
|
|
317
|
-
|
|
318
|
-
// Don't intercept when an element with its own undo is focused:
|
|
319
|
-
// - ProseMirror (TipTap text blocks have their own undo)
|
|
320
|
-
// - textarea/input (native browser undo for code blocks, math, titles, etc.)
|
|
321
348
|
const el = document.activeElement;
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
|
|
349
|
+
const inProseMirror = !!el?.closest('.ProseMirror');
|
|
350
|
+
const inFormField = el?.tagName === 'TEXTAREA' || el?.tagName === 'INPUT' || el?.tagName === 'SELECT';
|
|
351
|
+
|
|
352
|
+
// Undo/Redo: Ctrl+Z / Ctrl+Shift+Z
|
|
353
|
+
if (mod && event.key.toLowerCase() === 'z') {
|
|
354
|
+
if (inProseMirror || inFormField) return;
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
if (event.shiftKey) { props.blockEditor.redo(); } else { props.blockEditor.undo(); }
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
325
359
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
360
|
+
// Block operations only when a block is selected and not editing text
|
|
361
|
+
const selectedId = props.blockEditor.selectedBlockId.value;
|
|
362
|
+
if (!selectedId || inProseMirror || inFormField) return;
|
|
363
|
+
|
|
364
|
+
// Ctrl+Shift+ArrowUp: move block up
|
|
365
|
+
if (mod && event.shiftKey && event.key === 'ArrowUp') {
|
|
366
|
+
event.preventDefault();
|
|
367
|
+
props.blockEditor.moveBlockUp(selectedId);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Ctrl+Shift+ArrowDown: move block down
|
|
372
|
+
if (mod && event.shiftKey && event.key === 'ArrowDown') {
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
props.blockEditor.moveBlockDown(selectedId);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Ctrl+D: duplicate block
|
|
379
|
+
if (mod && event.key.toLowerCase() === 'd') {
|
|
380
|
+
event.preventDefault();
|
|
381
|
+
props.blockEditor.duplicateBlock(selectedId);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Delete / Backspace: remove selected block
|
|
386
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
props.blockEditor.removeBlock(selectedId);
|
|
389
|
+
return;
|
|
331
390
|
}
|
|
332
391
|
}
|
|
333
392
|
|
|
@@ -409,23 +468,45 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
409
468
|
class="cpub-floating-toolbar"
|
|
410
469
|
:style="{ top: floatingToolbar.top + 'px', left: floatingToolbar.left + 'px' }"
|
|
411
470
|
>
|
|
412
|
-
<button class="cpub-ft-btn" title="Bold" @mousedown.prevent="toggleMark('bold')">
|
|
471
|
+
<button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.bold }" title="Bold" @mousedown.prevent="toggleMark('bold')">
|
|
413
472
|
<i class="fa-solid fa-bold"></i>
|
|
414
473
|
</button>
|
|
415
|
-
<button class="cpub-ft-btn" title="Italic" @mousedown.prevent="toggleMark('italic')">
|
|
474
|
+
<button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.italic }" title="Italic" @mousedown.prevent="toggleMark('italic')">
|
|
416
475
|
<i class="fa-solid fa-italic"></i>
|
|
417
476
|
</button>
|
|
418
|
-
<button class="cpub-ft-btn" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
|
|
477
|
+
<button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.strike }" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
|
|
419
478
|
<i class="fa-solid fa-strikethrough"></i>
|
|
420
479
|
</button>
|
|
421
|
-
<button class="cpub-ft-btn" title="Inline code" @mousedown.prevent="toggleMark('code')">
|
|
480
|
+
<button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.code }" title="Inline code" @mousedown.prevent="toggleMark('code')">
|
|
422
481
|
<i class="fa-solid fa-code"></i>
|
|
423
482
|
</button>
|
|
424
483
|
<div class="cpub-ft-divider" />
|
|
425
|
-
<button class="cpub-ft-btn" title="Link" @mousedown.prevent="toggleLink">
|
|
484
|
+
<button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.link }" title="Link" @mousedown.prevent="toggleLink">
|
|
426
485
|
<i class="fa-solid fa-link"></i>
|
|
427
486
|
</button>
|
|
428
487
|
</div>
|
|
488
|
+
<!-- Inline link URL prompt -->
|
|
489
|
+
<div
|
|
490
|
+
v-if="linkPrompt.visible && floatingToolbar.visible"
|
|
491
|
+
class="cpub-link-prompt"
|
|
492
|
+
:style="{ top: (floatingToolbar.top + 38) + 'px', left: floatingToolbar.left + 'px' }"
|
|
493
|
+
>
|
|
494
|
+
<input
|
|
495
|
+
v-model="linkPrompt.url"
|
|
496
|
+
class="cpub-link-prompt-input"
|
|
497
|
+
type="url"
|
|
498
|
+
placeholder="https://..."
|
|
499
|
+
autofocus
|
|
500
|
+
@keydown.enter.prevent="applyLink"
|
|
501
|
+
@keydown.escape.prevent="cancelLink"
|
|
502
|
+
/>
|
|
503
|
+
<button class="cpub-link-prompt-btn" @mousedown.prevent="applyLink">
|
|
504
|
+
<i class="fa-solid fa-check"></i>
|
|
505
|
+
</button>
|
|
506
|
+
<button class="cpub-link-prompt-btn cpub-link-prompt-btn--cancel" @mousedown.prevent="cancelLink">
|
|
507
|
+
<i class="fa-solid fa-xmark"></i>
|
|
508
|
+
</button>
|
|
509
|
+
</div>
|
|
429
510
|
</Teleport>
|
|
430
511
|
</div>
|
|
431
512
|
</template>
|
|
@@ -536,10 +617,59 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
|
|
|
536
617
|
color: var(--surface, #fff);
|
|
537
618
|
}
|
|
538
619
|
|
|
620
|
+
.cpub-ft-btn--active {
|
|
621
|
+
background: var(--ft-surface);
|
|
622
|
+
color: var(--accent, #5b9cf6);
|
|
623
|
+
}
|
|
624
|
+
|
|
539
625
|
.cpub-ft-divider {
|
|
540
626
|
width: 2px;
|
|
541
627
|
height: 18px;
|
|
542
628
|
background: var(--ft-surface);
|
|
543
629
|
margin: 0 2px;
|
|
544
630
|
}
|
|
631
|
+
|
|
632
|
+
.cpub-link-prompt {
|
|
633
|
+
position: fixed;
|
|
634
|
+
z-index: 201;
|
|
635
|
+
display: flex;
|
|
636
|
+
align-items: center;
|
|
637
|
+
gap: 0;
|
|
638
|
+
background: var(--text, #1a1a1a);
|
|
639
|
+
border: var(--border-width-default, 2px) solid var(--border, #1a1a1a);
|
|
640
|
+
box-shadow: var(--shadow-md);
|
|
641
|
+
padding: 3px;
|
|
642
|
+
transform: translateX(-50%);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.cpub-link-prompt-input {
|
|
646
|
+
width: 200px;
|
|
647
|
+
padding: 4px 8px;
|
|
648
|
+
background: transparent;
|
|
649
|
+
border: none;
|
|
650
|
+
color: var(--surface3, #eaeae7);
|
|
651
|
+
font-family: var(--font-mono, monospace);
|
|
652
|
+
font-size: 11px;
|
|
653
|
+
outline: none;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
.cpub-link-prompt-input::placeholder {
|
|
657
|
+
color: rgba(255, 255, 255, 0.3);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
.cpub-link-prompt-btn {
|
|
661
|
+
width: 26px;
|
|
662
|
+
height: 26px;
|
|
663
|
+
display: flex;
|
|
664
|
+
align-items: center;
|
|
665
|
+
justify-content: center;
|
|
666
|
+
background: transparent;
|
|
667
|
+
border: none;
|
|
668
|
+
color: var(--surface3, #eaeae7);
|
|
669
|
+
cursor: pointer;
|
|
670
|
+
font-size: 10px;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
.cpub-link-prompt-btn:hover { background: rgba(255, 255, 255, 0.15); color: #fff; }
|
|
674
|
+
.cpub-link-prompt-btn--cancel:hover { background: var(--red, #e04030); color: #fff; }
|
|
545
675
|
</style>
|
|
@@ -47,6 +47,23 @@ function onDragLeave(): void {
|
|
|
47
47
|
transition: height 0.15s;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/* Always-visible thin line */
|
|
51
|
+
.cpub-insert-zone::before {
|
|
52
|
+
content: '';
|
|
53
|
+
position: absolute;
|
|
54
|
+
left: 0;
|
|
55
|
+
right: 0;
|
|
56
|
+
top: 50%;
|
|
57
|
+
height: 1px;
|
|
58
|
+
background: var(--border2, rgba(0, 0, 0, 0.06));
|
|
59
|
+
pointer-events: none;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.cpub-insert-zone:hover::before,
|
|
63
|
+
.cpub-insert-zone--dragover::before {
|
|
64
|
+
background: transparent;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
.cpub-insert-zone:hover,
|
|
51
68
|
.cpub-insert-zone--dragover {
|
|
52
69
|
height: 36px;
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* - Selected state (accent outline)
|
|
7
7
|
* - Click-to-select
|
|
8
8
|
*/
|
|
9
|
+
import { ref, watch } from 'vue';
|
|
9
10
|
import type { EditorBlock } from '../types.js';
|
|
10
11
|
|
|
11
12
|
const props = defineProps<{
|
|
@@ -23,6 +24,29 @@ const emit = defineEmits<{
|
|
|
23
24
|
'drag-end': [event: DragEvent];
|
|
24
25
|
}>();
|
|
25
26
|
|
|
27
|
+
// Two-step delete: first click arms, second click confirms
|
|
28
|
+
const deleteArmed = ref(false);
|
|
29
|
+
let deleteTimer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
|
|
31
|
+
function handleDelete(): void {
|
|
32
|
+
if (deleteArmed.value) {
|
|
33
|
+
emit('delete');
|
|
34
|
+
deleteArmed.value = false;
|
|
35
|
+
if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null; }
|
|
36
|
+
} else {
|
|
37
|
+
deleteArmed.value = true;
|
|
38
|
+
deleteTimer = setTimeout(() => { deleteArmed.value = false; }, 2000);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Reset armed state when block loses selection
|
|
43
|
+
watch(() => props.selected, (sel) => {
|
|
44
|
+
if (!sel) {
|
|
45
|
+
deleteArmed.value = false;
|
|
46
|
+
if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null; }
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
26
50
|
function onDragStart(event: DragEvent): void {
|
|
27
51
|
event.dataTransfer?.setData('text/plain', props.block.id);
|
|
28
52
|
event.dataTransfer!.effectAllowed = 'move';
|
|
@@ -53,6 +77,9 @@ function onDragEnd(event: DragEvent): void {
|
|
|
53
77
|
</button>
|
|
54
78
|
</div>
|
|
55
79
|
|
|
80
|
+
<!-- Block type badge (top-left, shown on hover) -->
|
|
81
|
+
<div class="cpub-block-type-badge">{{ block.type.replace(/_/g, ' ') }}</div>
|
|
82
|
+
|
|
56
83
|
<!-- Block controls (top-right, shown on hover) -->
|
|
57
84
|
<div class="cpub-block-controls">
|
|
58
85
|
<button class="cpub-block-ctrl" title="Move up" @click.stop="emit('move-up')">
|
|
@@ -64,8 +91,18 @@ function onDragEnd(event: DragEvent): void {
|
|
|
64
91
|
<button class="cpub-block-ctrl" title="Duplicate" @click.stop="emit('duplicate')">
|
|
65
92
|
<i class="fa-solid fa-copy"></i>
|
|
66
93
|
</button>
|
|
67
|
-
<button
|
|
68
|
-
|
|
94
|
+
<button
|
|
95
|
+
class="cpub-block-ctrl"
|
|
96
|
+
:class="deleteArmed ? 'cpub-block-ctrl--armed' : 'cpub-block-ctrl--danger'"
|
|
97
|
+
:title="deleteArmed ? 'Click again to confirm' : 'Delete'"
|
|
98
|
+
@click.stop="handleDelete"
|
|
99
|
+
>
|
|
100
|
+
<template v-if="deleteArmed">
|
|
101
|
+
<i class="fa-solid fa-check"></i>
|
|
102
|
+
</template>
|
|
103
|
+
<template v-else>
|
|
104
|
+
<i class="fa-solid fa-trash"></i>
|
|
105
|
+
</template>
|
|
69
106
|
</button>
|
|
70
107
|
</div>
|
|
71
108
|
|
|
@@ -133,6 +170,25 @@ function onDragEnd(event: DragEvent): void {
|
|
|
133
170
|
cursor: grabbing;
|
|
134
171
|
}
|
|
135
172
|
|
|
173
|
+
.cpub-block-type-badge {
|
|
174
|
+
position: absolute;
|
|
175
|
+
top: -22px;
|
|
176
|
+
left: 0;
|
|
177
|
+
font-family: var(--font-mono);
|
|
178
|
+
font-size: 9px;
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
text-transform: capitalize;
|
|
181
|
+
letter-spacing: 0.04em;
|
|
182
|
+
color: var(--text-faint);
|
|
183
|
+
opacity: 0;
|
|
184
|
+
transition: opacity 0.12s;
|
|
185
|
+
pointer-events: none;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.cpub-block-wrap:hover .cpub-block-type-badge {
|
|
189
|
+
opacity: 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
136
192
|
.cpub-block-controls {
|
|
137
193
|
--ctrl-surface: rgba(255, 255, 255, 0.15);
|
|
138
194
|
position: absolute;
|
|
@@ -177,6 +233,17 @@ function onDragEnd(event: DragEvent): void {
|
|
|
177
233
|
color: var(--surface);
|
|
178
234
|
}
|
|
179
235
|
|
|
236
|
+
.cpub-block-ctrl--armed {
|
|
237
|
+
background: var(--red);
|
|
238
|
+
color: var(--surface);
|
|
239
|
+
animation: cpub-pulse 0.6s ease-in-out infinite alternate;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
@keyframes cpub-pulse {
|
|
243
|
+
from { opacity: 1; }
|
|
244
|
+
to { opacity: 0.6; }
|
|
245
|
+
}
|
|
246
|
+
|
|
180
247
|
.cpub-block-inner {
|
|
181
248
|
min-height: 20px;
|
|
182
249
|
}
|