@teriusu/rich-editor 0.11.2 → 0.11.4
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/dist/components/BubbleToolbar.svelte +104 -143
- package/dist/components/BubbleToolbar.svelte.d.ts.map +1 -1
- package/dist/extensions/MbusVideo.d.ts.map +1 -1
- package/dist/extensions/MbusVideo.js +39 -1
- package/dist/extensions/VideoEmbed.d.ts.map +1 -1
- package/dist/extensions/VideoEmbed.js +5 -22
- package/dist/utils/embed.d.ts +22 -0
- package/dist/utils/embed.d.ts.map +1 -0
- package/dist/utils/embed.js +29 -0
- package/package.json +1 -1
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
ListChecks,
|
|
25
25
|
Quote,
|
|
26
26
|
ChevronDown,
|
|
27
|
+
Check,
|
|
27
28
|
Sigma,
|
|
28
29
|
} from "lucide-svelte";
|
|
29
30
|
import { cn } from "../utils/cn";
|
|
@@ -102,16 +103,6 @@
|
|
|
102
103
|
return t("paragraph");
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
function isParagraphActive(): boolean {
|
|
106
|
-
return (
|
|
107
|
-
!isActive("heading") &&
|
|
108
|
-
!isActive("bulletList") &&
|
|
109
|
-
!isActive("orderedList") &&
|
|
110
|
-
!isActive("taskList") &&
|
|
111
|
-
!isActive("blockquote")
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
106
|
async function addLink() {
|
|
116
107
|
const selection = editor.state.selection.getBookmark();
|
|
117
108
|
const documentBeforePrompt = editor.state.doc;
|
|
@@ -175,7 +166,9 @@
|
|
|
175
166
|
const themeTokens = [
|
|
176
167
|
'--border', '--background', '--foreground', '--popover', '--muted',
|
|
177
168
|
'--muted-foreground', '--primary', '--primary-foreground', '--accent',
|
|
178
|
-
'--accent-foreground', '--ring', '--hce-menu-z', '--hce-menu-surface'
|
|
169
|
+
'--accent-foreground', '--ring', '--hce-menu-z', '--hce-menu-surface',
|
|
170
|
+
'--wh-100', '--wh-500', '--gray-500', '--gray-700', '--gray-900',
|
|
171
|
+
'--primary-500', '--color-line', '--radius-8', '--radius-2xl', '--shadow-popup'
|
|
179
172
|
];
|
|
180
173
|
const syncTheme = () => {
|
|
181
174
|
const hostStyle = getComputedStyle(editor.view.dom);
|
|
@@ -192,7 +185,7 @@
|
|
|
192
185
|
const left = viewport?.offsetLeft ?? 0;
|
|
193
186
|
const top = viewport?.offsetTop ?? 0;
|
|
194
187
|
anchor.style.left = `${left + width / 2}px`;
|
|
195
|
-
anchor.style.top =
|
|
188
|
+
anchor.style.top = `calc(${top + height}px - env(safe-area-inset-bottom, 0px))`;
|
|
196
189
|
element.style.maxWidth = mobile ? `${Math.max(0, width - 16)}px` : '';
|
|
197
190
|
element.style.setProperty('--bubble-popup-height', `${Math.max(44, height - element.getBoundingClientRect().height - 24)}px`);
|
|
198
191
|
};
|
|
@@ -329,125 +322,55 @@
|
|
|
329
322
|
);
|
|
330
323
|
</script>
|
|
331
324
|
|
|
325
|
+
{#snippet blockItem(labelKey: EditorMessageKey, Icon: typeof Type, select: () => void)}
|
|
326
|
+
{@const selected = getCurrentBlockLabel() === t(labelKey)}
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
class="block-menu-item"
|
|
330
|
+
role="menuitemradio"
|
|
331
|
+
aria-checked={selected}
|
|
332
|
+
onclick={() => { select(); showBlockMenu = false; }}
|
|
333
|
+
>
|
|
334
|
+
<span class="block-menu-icon"><Icon size={16} /></span>
|
|
335
|
+
<span class="block-menu-label">{t(labelKey)}</span>
|
|
336
|
+
{#if selected}<span class="block-menu-check"><Check size={16} /></span>{/if}
|
|
337
|
+
</button>
|
|
338
|
+
{/snippet}
|
|
339
|
+
|
|
332
340
|
{#snippet blockPopover()}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
onclick={() => {
|
|
351
|
-
editor.chain().focus().setParagraph().run();
|
|
352
|
-
showBlockMenu = false;
|
|
353
|
-
}}
|
|
354
|
-
>
|
|
355
|
-
<Type size={12} /> {t('paragraph')}
|
|
356
|
-
</button>
|
|
357
|
-
{#each [1, 2, 3] as level (level)}
|
|
358
|
-
{#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
|
|
359
|
-
{@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
|
|
360
|
-
<button
|
|
361
|
-
type="button"
|
|
362
|
-
class={cn(
|
|
363
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
|
|
364
|
-
isActive("heading", { level })
|
|
365
|
-
? "hce-active"
|
|
366
|
-
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
367
|
-
)}
|
|
368
|
-
onclick={() => {
|
|
369
|
-
editor
|
|
370
|
-
.chain()
|
|
371
|
-
.focus()
|
|
372
|
-
.toggleHeading({ level: level as 1 | 2 | 3 })
|
|
373
|
-
.run();
|
|
374
|
-
showBlockMenu = false;
|
|
375
|
-
}}
|
|
376
|
-
>
|
|
377
|
-
<Icon size={12} /> {t(level === 1 ? 'heading1' : level === 2 ? 'heading2' : 'heading3')}
|
|
378
|
-
</button>
|
|
379
|
-
{/if}
|
|
380
|
-
{/each}
|
|
381
|
-
{#if has('bullet-list')}
|
|
382
|
-
<button
|
|
383
|
-
type="button"
|
|
384
|
-
class={cn(
|
|
385
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
|
|
386
|
-
isActive("bulletList")
|
|
387
|
-
? "hce-active"
|
|
388
|
-
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
389
|
-
)}
|
|
390
|
-
onclick={() => {
|
|
391
|
-
editor.chain().focus().toggleBulletList().run();
|
|
392
|
-
showBlockMenu = false;
|
|
393
|
-
}}
|
|
394
|
-
>
|
|
395
|
-
<List size={12} /> {t('bulletList')}
|
|
396
|
-
</button>
|
|
397
|
-
{/if}
|
|
398
|
-
{#if has('ordered-list')}
|
|
399
|
-
<button
|
|
400
|
-
type="button"
|
|
401
|
-
class={cn(
|
|
402
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
|
|
403
|
-
isActive("orderedList")
|
|
404
|
-
? "hce-active"
|
|
405
|
-
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
406
|
-
)}
|
|
407
|
-
onclick={() => {
|
|
408
|
-
editor.chain().focus().toggleOrderedList().run();
|
|
409
|
-
showBlockMenu = false;
|
|
410
|
-
}}
|
|
411
|
-
>
|
|
412
|
-
<ListOrdered size={12} /> {t('orderedList')}
|
|
413
|
-
</button>
|
|
414
|
-
{/if}
|
|
415
|
-
{#if has('checklist')}
|
|
416
|
-
<button
|
|
417
|
-
type="button"
|
|
418
|
-
class={cn(
|
|
419
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
|
|
420
|
-
isActive("taskList")
|
|
421
|
-
? "hce-active"
|
|
422
|
-
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
423
|
-
)}
|
|
424
|
-
onclick={() => {
|
|
425
|
-
editor.chain().focus().toggleTaskList().run();
|
|
426
|
-
showBlockMenu = false;
|
|
427
|
-
}}
|
|
428
|
-
>
|
|
429
|
-
<ListChecks size={12} /> {t('taskList')}
|
|
430
|
-
</button>
|
|
431
|
-
{/if}
|
|
432
|
-
{#if has('blockquote')}
|
|
433
|
-
<button
|
|
434
|
-
type="button"
|
|
435
|
-
class={cn(
|
|
436
|
-
"w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors",
|
|
437
|
-
isActive("blockquote")
|
|
438
|
-
? "hce-active"
|
|
439
|
-
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
|
440
|
-
)}
|
|
441
|
-
onclick={() => {
|
|
442
|
-
editor.chain().focus().toggleBlockquote().run();
|
|
443
|
-
showBlockMenu = false;
|
|
444
|
-
}}
|
|
445
|
-
>
|
|
446
|
-
<Quote size={12} /> {t('blockquote')}
|
|
447
|
-
</button>
|
|
448
|
-
{/if}
|
|
449
|
-
</div>
|
|
341
|
+
{#if showBlockMenu}
|
|
342
|
+
<div
|
|
343
|
+
bind:this={blockPopupEl}
|
|
344
|
+
class="bubble-popover bubble-block-menu absolute bottom-full left-0 mb-1"
|
|
345
|
+
onmousedown={(e) => e.preventDefault()}
|
|
346
|
+
role="menu"
|
|
347
|
+
aria-orientation="vertical"
|
|
348
|
+
tabindex="-1"
|
|
349
|
+
>
|
|
350
|
+
{@render blockItem('paragraph', Type, () => { editor.chain().focus().setParagraph().run(); })}
|
|
351
|
+
{#each [1, 2, 3] as level (level)}
|
|
352
|
+
{#if has(level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3')}
|
|
353
|
+
{@const Icon = level === 1 ? Heading1 : level === 2 ? Heading2 : Heading3}
|
|
354
|
+
{@render blockItem(
|
|
355
|
+
level === 1 ? 'heading1' : level === 2 ? 'heading2' : 'heading3', Icon,
|
|
356
|
+
() => { editor.chain().focus().toggleHeading({ level: level as 1 | 2 | 3 }).run(); }
|
|
357
|
+
)}
|
|
450
358
|
{/if}
|
|
359
|
+
{/each}
|
|
360
|
+
{#if has('bullet-list')}
|
|
361
|
+
{@render blockItem('bulletList', List, () => { editor.chain().focus().toggleBulletList().run(); })}
|
|
362
|
+
{/if}
|
|
363
|
+
{#if has('ordered-list')}
|
|
364
|
+
{@render blockItem('orderedList', ListOrdered, () => { editor.chain().focus().toggleOrderedList().run(); })}
|
|
365
|
+
{/if}
|
|
366
|
+
{#if has('checklist')}
|
|
367
|
+
{@render blockItem('taskList', ListChecks, () => { editor.chain().focus().toggleTaskList().run(); })}
|
|
368
|
+
{/if}
|
|
369
|
+
{#if has('blockquote')}
|
|
370
|
+
{@render blockItem('blockquote', Quote, () => { editor.chain().focus().toggleBlockquote().run(); })}
|
|
371
|
+
{/if}
|
|
372
|
+
</div>
|
|
373
|
+
{/if}
|
|
451
374
|
{/snippet}
|
|
452
375
|
|
|
453
376
|
{#snippet colorPopover()}
|
|
@@ -509,12 +432,13 @@
|
|
|
509
432
|
type="button"
|
|
510
433
|
onclick={() => (showBlockMenu = !showBlockMenu)}
|
|
511
434
|
aria-haspopup="menu"
|
|
435
|
+
aria-label={getCurrentBlockLabel()}
|
|
512
436
|
aria-expanded={showBlockMenu}
|
|
513
|
-
class="flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
437
|
+
class="bubble-block-trigger flex items-center gap-1 px-2.5 py-1.5 rounded-full text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
|
514
438
|
>
|
|
515
|
-
<Type size={12} />
|
|
516
|
-
{getCurrentBlockLabel()}
|
|
517
|
-
<ChevronDown size={12} />
|
|
439
|
+
<Type size={mobile ? 14 : 12} />
|
|
440
|
+
{#if !mobile}{getCurrentBlockLabel()}{/if}
|
|
441
|
+
<ChevronDown size={mobile ? 8 : 12} />
|
|
518
442
|
</button>
|
|
519
443
|
{#if !mobile}{@render blockPopover()}{/if}
|
|
520
444
|
</div>
|
|
@@ -688,28 +612,30 @@
|
|
|
688
612
|
|
|
689
613
|
<style>
|
|
690
614
|
.mobile .hce-floating-panel {
|
|
691
|
-
border-radius:
|
|
692
|
-
padding:
|
|
693
|
-
padding-bottom: max(4px, env(safe-area-inset-bottom));
|
|
615
|
+
border-radius: 8px;
|
|
616
|
+
padding: 1px 3px;
|
|
694
617
|
}
|
|
695
618
|
.mobile .bubble-toolbar-rail {
|
|
696
619
|
display: flex;
|
|
697
620
|
flex-wrap: nowrap;
|
|
698
621
|
max-width: 100%;
|
|
699
|
-
|
|
622
|
+
gap: 1px;
|
|
700
623
|
padding: 0;
|
|
701
|
-
scrollbar-width: none;
|
|
702
|
-
touch-action: pan-x;
|
|
703
624
|
}
|
|
704
|
-
.mobile .bubble-toolbar-rail::-webkit-scrollbar { display: none; }
|
|
705
625
|
.mobile .bubble-toolbar-rail > * { flex-shrink: 0; }
|
|
706
|
-
.mobile button {
|
|
707
|
-
|
|
708
|
-
|
|
626
|
+
.mobile .bubble-toolbar-rail button {
|
|
627
|
+
width: 28px;
|
|
628
|
+
height: 28px;
|
|
629
|
+
padding: 0;
|
|
709
630
|
display: inline-flex;
|
|
710
631
|
align-items: center;
|
|
711
632
|
justify-content: center;
|
|
712
633
|
}
|
|
634
|
+
.mobile .bubble-toolbar-rail .bubble-block-trigger {
|
|
635
|
+
width: 32px;
|
|
636
|
+
gap: 1px;
|
|
637
|
+
}
|
|
638
|
+
.mobile .bubble-popover:not(.bubble-block-menu) button { min-width: 44px; min-height: 44px; }
|
|
713
639
|
.mobile input[type="color"] { min-width: 44px; min-height: 44px; }
|
|
714
640
|
.mobile .bubble-divider { display: none; }
|
|
715
641
|
.mobile .bubble-popover-anchor { position: static; }
|
|
@@ -719,4 +645,39 @@
|
|
|
719
645
|
max-height: min(var(--bubble-popup-height, 50dvh), 300px);
|
|
720
646
|
overflow-y: auto;
|
|
721
647
|
}
|
|
648
|
+
.bubble-block-menu {
|
|
649
|
+
width: 200px;
|
|
650
|
+
box-sizing: border-box;
|
|
651
|
+
padding: 4px;
|
|
652
|
+
background: var(--wh-100, var(--popover, var(--background, #fff)));
|
|
653
|
+
border: 1px solid var(--color-line, var(--border));
|
|
654
|
+
border-radius: var(--radius-2xl, 12px);
|
|
655
|
+
box-shadow: var(--shadow-popup, 0 8px 24px rgb(0 0 0 / 12%));
|
|
656
|
+
}
|
|
657
|
+
.mobile .bubble-block-menu { right: auto; max-width: 100%; }
|
|
658
|
+
.block-menu-item {
|
|
659
|
+
display: flex;
|
|
660
|
+
align-items: center;
|
|
661
|
+
width: 100%;
|
|
662
|
+
min-height: 32px;
|
|
663
|
+
gap: 8px;
|
|
664
|
+
padding: 6px 8px;
|
|
665
|
+
border-radius: var(--radius-8, 8px);
|
|
666
|
+
color: var(--gray-700, var(--foreground));
|
|
667
|
+
font: inherit;
|
|
668
|
+
font-size: 13px;
|
|
669
|
+
line-height: 20px;
|
|
670
|
+
text-align: left;
|
|
671
|
+
white-space: nowrap;
|
|
672
|
+
}
|
|
673
|
+
.block-menu-item:hover, .block-menu-item:focus-visible {
|
|
674
|
+
background: var(--wh-500, var(--muted));
|
|
675
|
+
}
|
|
676
|
+
.block-menu-item[aria-checked='true'] {
|
|
677
|
+
color: var(--gray-900, var(--foreground));
|
|
678
|
+
font-weight: 600;
|
|
679
|
+
}
|
|
680
|
+
.block-menu-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
681
|
+
.block-menu-icon { display: inline-flex; flex: none; color: var(--gray-500, var(--muted-foreground)); }
|
|
682
|
+
.block-menu-check { display: inline-flex; flex: none; color: var(--primary-500, var(--primary)); }
|
|
722
683
|
</style>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"BubbleToolbar.svelte.d.ts","sourceRoot":"","sources":["../../src/components/BubbleToolbar.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AA2B3C,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,EAA4C,KAAK,gBAAgB,EAAE,MAAM,SAAS,CAAC;AAEzF,KAAK,gBAAgB,GAAI;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,aAAa,CAAC;IAC7B,8BAA8B;IAC9B,CAAC,CAAC,EAAE,gBAAgB,CAAC;CACtB,CAAC;AAmfJ,QAAA,MAAM,aAAa,sDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MbusVideo.d.ts","sourceRoot":"","sources":["../../src/extensions/MbusVideo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"MbusVideo.d.ts","sourceRoot":"","sources":["../../src/extensions/MbusVideo.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAWrD,MAAM,WAAW,gBAAgB;IAChC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC7B,UAAU,QAAQ,CAAC,UAAU;QAC5B,SAAS,EAAE;YACV,YAAY,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,KAAK,UAAU,CAAC;SACrE,CAAC;KACF;CACD;AAED,eAAO,MAAM,SAAS,6BA0NpB,CAAC"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Node, mergeAttributes } from "@tiptap/core";
|
|
2
2
|
import { attachResize } from "../utils/resize";
|
|
3
|
+
import { applyCrossOriginEmbedAttrs, canEmbedCrossOrigin } from "../utils/embed";
|
|
3
4
|
import { getEditorTranslator } from "../i18n";
|
|
4
5
|
import { mediaRatioCss, normalizeMediaAlign, normalizeMediaHeight, normalizeMediaRatio } from "../utils/media-size";
|
|
5
6
|
export const MbusVideo = Node.create({
|
|
@@ -99,16 +100,53 @@ export const MbusVideo = Node.create({
|
|
|
99
100
|
dom.style.marginRight = align === "center" ? "auto" : "";
|
|
100
101
|
};
|
|
101
102
|
applyLayout(node.attrs);
|
|
102
|
-
|
|
103
|
+
/*
|
|
104
|
+
* ⚠️ **`credentialless` 를 반드시 붙인다** — `VideoEmbed` 와 같은 이유·같은
|
|
105
|
+
* 레시피다(`utils/embed.ts`). 정올은 전 문서가 `COEP: require-corp` 라, 이
|
|
106
|
+
* 속성이 없으면 mbus iframe 이 **크롬에서도 통째로 막히고** 그 자리에 크롬의
|
|
107
|
+
* 오류 화면(`play.mbus.tv 에서 연결을 거부했습니다`)이 뜬다.
|
|
108
|
+
*
|
|
109
|
+
* 이 노드만 그 처리가 빠져 있었다. 저장한 뒤 읽는 화면은 호스트의 정적 렌더
|
|
110
|
+
* (`trinity …/tiptap/static-enhance.ts` 의 `buildVideoBox`)가 같은 상자를 다시
|
|
111
|
+
* 지으면서 속성을 붙이므로 **에디터에서만** 거부로 보였다 — "영상을 올리면
|
|
112
|
+
* 연결이 거부된다"는 신고의 실체가 이 비대칭이다.
|
|
113
|
+
* (브라우저 실측, 격리된 문서: 속성 없는 mbus iframe = COEP 위반 보고 1건,
|
|
114
|
+
* 속성 있는 쪽 = 0건.)
|
|
115
|
+
*/
|
|
116
|
+
if (node.attrs.src && canEmbedCrossOrigin()) {
|
|
103
117
|
const iframe = document.createElement("iframe");
|
|
104
118
|
iframe.src = node.attrs.src;
|
|
105
119
|
iframe.allow = "autoplay; fullscreen; encrypted-media; picture-in-picture";
|
|
106
120
|
iframe.setAttribute("allowfullscreen", "");
|
|
107
121
|
iframe.setAttribute("loading", "lazy");
|
|
122
|
+
applyCrossOriginEmbedAttrs(iframe);
|
|
108
123
|
iframe.style.cssText =
|
|
109
124
|
"position:absolute;inset:0;width:100%;height:100%;border:0;display:block;";
|
|
110
125
|
aspect.appendChild(iframe);
|
|
111
126
|
}
|
|
127
|
+
else if (node.attrs.src) {
|
|
128
|
+
/*
|
|
129
|
+
* 실을 수 없는 브라우저(파폭·사파리 + 격리) — **빈 박스로 두지 않는다.**
|
|
130
|
+
* 그러면 영상이 깨진 줄 알지 이유를 모른다. `VideoEmbed`·호스트 정적 렌더와
|
|
131
|
+
* **같은 문구·같은 모양**의 포스터를 세운다.
|
|
132
|
+
*/
|
|
133
|
+
const poster = document.createElement("div");
|
|
134
|
+
poster.style.cssText =
|
|
135
|
+
"position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:16px;text-align:center;color:#c7cbd4;";
|
|
136
|
+
const label = document.createElement("span");
|
|
137
|
+
label.textContent = t("videoCannotEmbed");
|
|
138
|
+
label.style.cssText = "font-size:13px;line-height:1.5;";
|
|
139
|
+
poster.appendChild(label);
|
|
140
|
+
const open = document.createElement("a");
|
|
141
|
+
open.href = node.attrs.src;
|
|
142
|
+
open.target = "_blank";
|
|
143
|
+
open.rel = "noopener noreferrer";
|
|
144
|
+
open.textContent = t("openInNewTab");
|
|
145
|
+
open.style.cssText =
|
|
146
|
+
"font-size:13px;font-weight:600;color:#fff;background:rgba(255,255,255,0.16);border-radius:6px;padding:6px 14px;text-decoration:none;";
|
|
147
|
+
poster.appendChild(open);
|
|
148
|
+
aspect.appendChild(poster);
|
|
149
|
+
}
|
|
112
150
|
if (editor.isEditable) {
|
|
113
151
|
const del = document.createElement("button");
|
|
114
152
|
del.type = "button";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"VideoEmbed.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoEmbed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"VideoEmbed.d.ts","sourceRoot":"","sources":["../../src/extensions/VideoEmbed.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,IAAI,EAAmB,MAAM,cAAc,CAAC;AAYrD,MAAM,WAAW,iBAAiB;IACjC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACxC;AAED,OAAO,QAAQ,cAAc,CAAC;IAC7B,UAAU,QAAQ,CAAC,UAAU;QAC5B,UAAU,EAAE;YACX,aAAa,EAAE,CAAC,KAAK,EAAE;gBAAE,GAAG,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAA;aAAE,KAAK,UAAU,CAAC;SACtE,CAAC;KACF;CACD;AAyBD;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuC1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CA2B9C;AAOD,eAAO,MAAM,UAAU,8BA+RrB,CAAC"}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { Node, mergeAttributes } from "@tiptap/core";
|
|
10
10
|
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
|
11
11
|
import { attachResize } from "../utils/resize";
|
|
12
|
+
import { applyCrossOriginEmbedAttrs, canEmbedCrossOrigin } from "../utils/embed";
|
|
12
13
|
import { getEditorTranslator } from "../i18n";
|
|
13
14
|
import { mediaRatioCss, normalizeMediaAlign, normalizeMediaHeight, normalizeMediaRatio } from "../utils/media-size";
|
|
14
15
|
/** `1m30s` · `90` 처럼 적히는 유튜브 타임스탬프를 초로 바꾼다. */
|
|
@@ -126,24 +127,10 @@ export function toEmbedUrl(raw) {
|
|
|
126
127
|
}
|
|
127
128
|
return trimmed;
|
|
128
129
|
}
|
|
129
|
-
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* ⚠️ 정올은 전 브라우저에 `COEP: require-corp`(교차 출처 격리)를 건다. 격리된 문서에
|
|
133
|
-
* 자기 COEP 를 안 보내는 제3자 iframe(유튜브·Vimeo·mbus 전부 해당)을 넣는 방법은
|
|
134
|
-
* `credentialless` 속성뿐인데 **크로미움 전용**이다(파이어폭스 미구현·사파리 거부).
|
|
135
|
-
* 그래서 정올 파폭·사파리에서는 mbus 영상이 지금 **빈 회색 박스**로 남아 있다.
|
|
136
|
-
*
|
|
137
|
-
* 격리되지 않은 문서(코드패스의 비-크로미움 등)에서는 평범한 크로스오리진 iframe 이라
|
|
138
|
-
* 아무 문제가 없다 — 그래서 격리 여부와 지원 여부를 **함께** 본다.
|
|
130
|
+
/*
|
|
131
|
+
* 판정은 `utils/embed.ts` 로 옮겼다 — `MbusVideo` 가 같은 규칙을 써야 하는데 여기
|
|
132
|
+
* 모듈 안에만 있어서 그쪽이 통째로 빠뜨리고 있었다(그 노드 주석 참조).
|
|
139
133
|
*/
|
|
140
|
-
function canEmbedCrossOrigin() {
|
|
141
|
-
if (typeof window === "undefined")
|
|
142
|
-
return true;
|
|
143
|
-
if (!window.crossOriginIsolated)
|
|
144
|
-
return true;
|
|
145
|
-
return "credentialless" in HTMLIFrameElement.prototype;
|
|
146
|
-
}
|
|
147
134
|
export const VideoEmbed = Node.create({
|
|
148
135
|
name: "videoEmbed",
|
|
149
136
|
group: "block",
|
|
@@ -261,11 +248,7 @@ export const VideoEmbed = Node.create({
|
|
|
261
248
|
iframe.allow = "autoplay; fullscreen; encrypted-media; picture-in-picture";
|
|
262
249
|
iframe.setAttribute("allowfullscreen", "");
|
|
263
250
|
iframe.setAttribute("loading", "lazy");
|
|
264
|
-
|
|
265
|
-
* 격리된 문서에서 COEP 없는 영상을 싣는 유일한 통로. 크로미움이 아니면 이
|
|
266
|
-
* 속성 자체가 무시되므로 조건 없이 붙여도 해가 없다.
|
|
267
|
-
*/
|
|
268
|
-
iframe.setAttribute("credentialless", "");
|
|
251
|
+
applyCrossOriginEmbedAttrs(iframe);
|
|
269
252
|
iframe.style.cssText =
|
|
270
253
|
"position:absolute;inset:0;width:100%;height:100%;border:0;display:block;";
|
|
271
254
|
aspect.appendChild(iframe);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「이 문서에서 **COEP 를 안 보내는 남의 iframe** 을 실을 수 있는가」.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 정올은 전 문서에 `Cross-Origin-Embedder-Policy: require-corp`(교차 출처 격리)를
|
|
5
|
+
* 건다. 격리된 문서에 자기 COEP 를 안 보내는 제3자 iframe(유튜브·Vimeo·mbus 전부
|
|
6
|
+
* 해당)을 넣는 방법은 **`credentialless` 속성뿐**인데 크로미움 전용이다
|
|
7
|
+
* (파이어폭스 미구현·사파리 거부).
|
|
8
|
+
*
|
|
9
|
+
* 격리되지 않은 문서(코드패스의 비-크로미움 등)에서는 평범한 크로스오리진 iframe 이라
|
|
10
|
+
* 아무 문제가 없다 — 그래서 격리 여부와 지원 여부를 **함께** 본다.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 이 판정과 `applyCrossOriginEmbedAttrs` 는 **항상 짝으로** 쓴다. 한쪽만 쓰면
|
|
13
|
+
* 격리된 문서에서 브라우저가 iframe 을 막고 크롬이 제 오류 화면
|
|
14
|
+
* (`…에서 연결을 거부했습니다`)을 그 자리에 그린다 — `MbusVideo` 가 실제로 그랬다.
|
|
15
|
+
*/
|
|
16
|
+
export declare function canEmbedCrossOrigin(): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* 격리된 문서에서 COEP 없는 영상을 싣는 유일한 통로. 크로미움이 아니면 이 속성 자체가
|
|
19
|
+
* 무시되므로 조건 없이 붙여도 해가 없다.
|
|
20
|
+
*/
|
|
21
|
+
export declare function applyCrossOriginEmbedAttrs(iframe: HTMLIFrameElement): void;
|
|
22
|
+
//# sourceMappingURL=embed.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"embed.d.ts","sourceRoot":"","sources":["../../src/utils/embed.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,IAAI,OAAO,CAI7C;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAE1E"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「이 문서에서 **COEP 를 안 보내는 남의 iframe** 을 실을 수 있는가」.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ 정올은 전 문서에 `Cross-Origin-Embedder-Policy: require-corp`(교차 출처 격리)를
|
|
5
|
+
* 건다. 격리된 문서에 자기 COEP 를 안 보내는 제3자 iframe(유튜브·Vimeo·mbus 전부
|
|
6
|
+
* 해당)을 넣는 방법은 **`credentialless` 속성뿐**인데 크로미움 전용이다
|
|
7
|
+
* (파이어폭스 미구현·사파리 거부).
|
|
8
|
+
*
|
|
9
|
+
* 격리되지 않은 문서(코드패스의 비-크로미움 등)에서는 평범한 크로스오리진 iframe 이라
|
|
10
|
+
* 아무 문제가 없다 — 그래서 격리 여부와 지원 여부를 **함께** 본다.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ 이 판정과 `applyCrossOriginEmbedAttrs` 는 **항상 짝으로** 쓴다. 한쪽만 쓰면
|
|
13
|
+
* 격리된 문서에서 브라우저가 iframe 을 막고 크롬이 제 오류 화면
|
|
14
|
+
* (`…에서 연결을 거부했습니다`)을 그 자리에 그린다 — `MbusVideo` 가 실제로 그랬다.
|
|
15
|
+
*/
|
|
16
|
+
export function canEmbedCrossOrigin() {
|
|
17
|
+
if (typeof window === "undefined")
|
|
18
|
+
return true;
|
|
19
|
+
if (!window.crossOriginIsolated)
|
|
20
|
+
return true;
|
|
21
|
+
return "credentialless" in HTMLIFrameElement.prototype;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 격리된 문서에서 COEP 없는 영상을 싣는 유일한 통로. 크로미움이 아니면 이 속성 자체가
|
|
25
|
+
* 무시되므로 조건 없이 붙여도 해가 없다.
|
|
26
|
+
*/
|
|
27
|
+
export function applyCrossOriginEmbedAttrs(iframe) {
|
|
28
|
+
iframe.setAttribute("credentialless", "");
|
|
29
|
+
}
|