게시물 HTML iframe 블록 추가
This commit is contained in:
@@ -132,9 +132,9 @@ const blockCommands = [
|
||||
},
|
||||
{
|
||||
type: 'embed',
|
||||
label: '임베드',
|
||||
description: 'YouTube 등 외부 링크 삽입',
|
||||
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브']
|
||||
label: 'HTML',
|
||||
description: 'URL 또는 iframe HTML 삽입',
|
||||
keywords: ['html', 'iframe', 'embed', 'youtube', 'link', '임베드', '유튜브']
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
@@ -2281,12 +2281,12 @@ defineExpose({
|
||||
<input
|
||||
v-model="block.url"
|
||||
class="admin-block-editor__embed-url w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
||||
type="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
type="text"
|
||||
placeholder="https://www.youtube.com/watch?v=... 또는 iframe HTML"
|
||||
@input="updateStructuredBlock"
|
||||
>
|
||||
<p class="admin-block-editor__embed-help mt-2 text-xs text-muted">
|
||||
YouTube 링크는 공개 화면에서 영상으로 표시됩니다.
|
||||
URL 또는 iframe HTML은 공개 화면에서 HTML 블록으로 표시됩니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ const panelTitle = computed(() => {
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return '임베드'
|
||||
return 'HTML'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'quote') {
|
||||
@@ -155,7 +155,7 @@ const panelMeta = computed(() => {
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return 'YouTube·X 등 URL'
|
||||
return 'URL·iframe'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'quote') {
|
||||
@@ -254,17 +254,16 @@ const onPanelFocusOut = (event) => {
|
||||
|
||||
<template v-else-if="panel.kind === 'embed'">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">임베드 URL</span>
|
||||
<input
|
||||
class="rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 text-sm text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||
<span class="font-semibold text-[#394047]">HTML 코드 또는 URL</span>
|
||||
<textarea
|
||||
class="min-h-28 resize-y rounded border border-[#d7dde2] bg-[#eff1f2] px-3 py-2 font-mono text-xs leading-5 text-[#15171a] outline-none transition-colors focus:border-[#8e9cac]"
|
||||
:value="panel.url"
|
||||
type="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
placeholder="https://www.youtube.com/watch?v=... 또는 <iframe src="..."></iframe>"
|
||||
@input="emit('update-embed-url', $event.target.value)"
|
||||
>
|
||||
/>
|
||||
</label>
|
||||
<p class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·YouTube Shorts, X(트위터) 게시물 URL을 지원합니다. 그 외 URL은 링크 카드로 표시됩니다.
|
||||
YouTube·YouTube Shorts, X(트위터) 게시물 URL과 외부 서비스 iframe 코드를 지원합니다. iframe은 src 등 허용된 속성만 사용합니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1472,18 +1472,39 @@ const getSelectionState = () => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTextareaSelection = (start, end = start, options = {}) => {
|
||||
const previousScrollTop = options.preserveScroll ? textareaRef.value?.scrollTop : null
|
||||
const scrollContainer = options.preserveScroll && import.meta.client
|
||||
? getNearestScrollContainer(editorRootRef.value)
|
||||
: null
|
||||
const previousScrollState = options.preserveScroll
|
||||
? {
|
||||
textareaTop: textareaRef.value?.scrollTop,
|
||||
containerTop: scrollContainer?.scrollTop,
|
||||
windowX: import.meta.client ? window.scrollX : 0,
|
||||
windowY: import.meta.client ? window.scrollY : 0
|
||||
}
|
||||
: null
|
||||
|
||||
/**
|
||||
* textarea 스크롤 위치를 복원한다.
|
||||
* textarea와 에디터 스크롤 위치를 복원한다.
|
||||
* @param {HTMLTextAreaElement} textarea - 대상 textarea
|
||||
* @returns {void}
|
||||
*/
|
||||
const restoreScroll = (textarea) => {
|
||||
if (!Number.isFinite(previousScrollTop)) {
|
||||
if (!previousScrollState) {
|
||||
return
|
||||
}
|
||||
|
||||
textarea.scrollTop = previousScrollTop
|
||||
if (Number.isFinite(previousScrollState.textareaTop)) {
|
||||
textarea.scrollTop = previousScrollState.textareaTop
|
||||
}
|
||||
|
||||
if (scrollContainer && Number.isFinite(previousScrollState.containerTop)) {
|
||||
scrollContainer.scrollTop = previousScrollState.containerTop
|
||||
}
|
||||
|
||||
if (import.meta.client && Number.isFinite(previousScrollState.windowY)) {
|
||||
window.scrollTo(previousScrollState.windowX, previousScrollState.windowY)
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
@@ -2729,8 +2750,8 @@ const appendImagesToActiveGallery = (images) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 임베드 URL을 수정한다.
|
||||
* @param {string} url - 임베드 URL
|
||||
* 현재 HTML 블록 값을 수정한다.
|
||||
* @param {string} url - HTML 블록 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveEmbedUrl = (url) => {
|
||||
@@ -2748,6 +2769,11 @@ const updateActiveEmbedUrl = (url) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmed.includes('\n') || /^<iframe\b/i.test(trimmed)) {
|
||||
replaceLineRange(block.startLine, block.endLine, [':::embed', trimmed, ':::'], false)
|
||||
return
|
||||
}
|
||||
|
||||
replaceLineRange(block.startLine, block.endLine, [trimmed], false)
|
||||
}
|
||||
|
||||
|
||||
@@ -1154,8 +1154,8 @@ const onBlockPanelAddGalleryImages = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 패널에서 임베드 URL을 수정한다.
|
||||
* @param {string} url - 임베드 URL
|
||||
* 블록 패널에서 HTML 임베드 값을 수정한다.
|
||||
* @param {string} url - HTML 임베드 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const onBlockPanelUpdateEmbedUrl = (url) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
parseCalloutOptions
|
||||
} from '../../lib/markdown-callout.js'
|
||||
import { createHeadingIdFactory } from '../../lib/markdown-toc.js'
|
||||
import { isIframeEmbedMarkup } from '../../lib/markdown-iframe-embed.js'
|
||||
import ContentMarkdownCodeBlockEditor from './ContentMarkdownCodeBlockEditor.vue'
|
||||
import ProseCodeBlock from './ProseCodeBlock.vue'
|
||||
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
|
||||
@@ -259,6 +260,7 @@ const isMarkdownBlockStart = (line) => {
|
||||
trimmedLine.startsWith('```') ||
|
||||
trimmedLine === '---' ||
|
||||
isStandaloneUrlLine(trimmedLine) ||
|
||||
isIframeEmbedMarkup(trimmedLine) ||
|
||||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||
trimmedLine.startsWith('> ') ||
|
||||
/^- /.test(trimmedLine) ||
|
||||
@@ -721,6 +723,17 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isIframeEmbedMarkup(trimmedLine)) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('embed', '', null, `block-${blocks.length}`, { url: trimmedLine }),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
@@ -3311,14 +3324,14 @@ onBeforeUnmount(() => {
|
||||
data-preview-card-block="true"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="임베드 블록"
|
||||
aria-label="HTML 블록"
|
||||
@mousedown.capture="focusPreviewCardBlock"
|
||||
@keydown="onPreviewCardKeydown($event, block)"
|
||||
>
|
||||
<button
|
||||
class="content-markdown-renderer__embed-delete content-markdown-renderer__preview-card-action absolute right-2 top-2 z-10 rounded bg-[#15171a]/85 px-2.5 py-1 text-xs font-semibold text-white opacity-0 shadow transition-opacity hover:bg-[#15171a] focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--site-accent)] group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
type="button"
|
||||
aria-label="임베드 삭제"
|
||||
aria-label="HTML 블록 삭제"
|
||||
@click.stop="deletePreviewCardBlock(block)"
|
||||
>
|
||||
삭제
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import { parseIframeEmbedMarkup } from '../../lib/markdown-iframe-embed.js'
|
||||
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
@@ -98,6 +100,7 @@ const youtubeId = computed(() => getYouTubeId(props.url))
|
||||
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||
const tweetId = computed(() => getTweetId(props.url))
|
||||
const mastodonEmbedUrl = computed(() => getMastodonEmbedUrl(props.url))
|
||||
const customIframe = computed(() => parseIframeEmbedMarkup(props.url))
|
||||
const mastodonIframeRef = ref(null)
|
||||
const mastodonEmbedHeight = ref(640)
|
||||
const mastodonEmbedId = ref(0)
|
||||
@@ -230,6 +233,18 @@ watch(mastodonEmbedUrl, () => {
|
||||
scrolling="no"
|
||||
@load="requestMastodonEmbedHeight"
|
||||
/>
|
||||
<iframe
|
||||
v-else-if="customIframe"
|
||||
class="prose-embed__custom block w-full border-0"
|
||||
:src="customIframe.src"
|
||||
:title="customIframe.title"
|
||||
:allow="customIframe.allow"
|
||||
:height="customIframe.height || 480"
|
||||
:width="customIframe.width || undefined"
|
||||
:allowfullscreen="customIframe.allowFullscreen"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms allow-presentation"
|
||||
/>
|
||||
<a
|
||||
v-else-if="safeExternalUrl"
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-[var(--site-text)] hover:opacity-70"
|
||||
|
||||
Reference in New Issue
Block a user