게시물 HTML 블록 분리
This commit is contained in:
@@ -114,10 +114,18 @@ const panelTitle = computed(() => {
|
||||
return '텍스트 색상'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
if (props.panel.kind === 'html') {
|
||||
return 'HTML'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'link') {
|
||||
return '링크'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'iframe') {
|
||||
return 'iframe'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'quote') {
|
||||
return '인용'
|
||||
}
|
||||
@@ -154,8 +162,16 @@ const panelMeta = computed(() => {
|
||||
return '선택한 텍스트'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'embed') {
|
||||
return 'URL·iframe'
|
||||
if (props.panel.kind === 'html') {
|
||||
return '사용자 HTML'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'link') {
|
||||
return 'URL 카드'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'iframe') {
|
||||
return 'sandbox iframe'
|
||||
}
|
||||
|
||||
if (props.panel.kind === 'quote') {
|
||||
@@ -252,17 +268,25 @@ const onPanelFocusOut = (event) => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="panel.kind === 'embed'">
|
||||
<template v-else-if="['html', 'link', 'iframe'].includes(panel.kind)">
|
||||
<label class="admin-editor-block-panel__field grid gap-2 text-sm">
|
||||
<span class="font-semibold text-[#394047]">HTML 코드 또는 URL</span>
|
||||
<span class="font-semibold text-[#394047]">
|
||||
{{ panel.kind === 'link' ? 'URL' : panel.kind === 'iframe' ? 'iframe 코드' : 'HTML 코드' }}
|
||||
</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"
|
||||
placeholder="https://www.youtube.com/watch?v=... 또는 <iframe src="..."></iframe>"
|
||||
:placeholder="panel.kind === 'link' ? 'https://example.com' : panel.kind === 'iframe' ? '<iframe src="https://..."></iframe>' : '<table>...</table>'"
|
||||
@input="emit('update-embed-url', $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<p class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
<p v-if="panel.kind === 'html'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
본문 HTML은 그대로 표시하되 script, iframe, 이벤트 속성 같은 실행성 코드는 제거합니다. iframe은 /iframe 블록을 사용합니다.
|
||||
</p>
|
||||
<p v-else-if="panel.kind === 'link'" class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·X(트위터)·Mastodon URL은 임베드로, 그 외 외부 URL은 링크 카드로 표시합니다.
|
||||
</p>
|
||||
<p v-else class="admin-editor-block-panel__hint mt-3 text-xs leading-relaxed text-[#8e9cac]">
|
||||
YouTube·YouTube Shorts, X(트위터) 게시물 URL과 외부 서비스 iframe 코드를 지원합니다. iframe은 src 등 허용된 속성만 사용합니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
@@ -2800,14 +2800,14 @@ const appendImagesToActiveGallery = (images) => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 HTML 블록 값을 수정한다.
|
||||
* @param {string} url - HTML 블록 값
|
||||
* 현재 링크·iframe·HTML 블록 값을 수정한다.
|
||||
* @param {string} url - 블록 값
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateActiveEmbedUrl = (url) => {
|
||||
const block = activeBlockContext.value
|
||||
|
||||
if (!block || block.kind !== 'embed') {
|
||||
if (!block || !['link', 'iframe', 'html'].includes(block.kind)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2819,12 +2819,7 @@ 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)
|
||||
replaceLineRange(block.startLine, block.endLine, [`:::${block.kind}`, trimmed, ':::'], false)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -123,8 +123,8 @@ const props = defineProps({
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.8 2H3.2C1.985 2 1 2.895 1 4v16c0 1.105.985 2 2.2 2h17.6c1.215 0 2.2-.895 2.2-2V4c0-1.105-.985-2-2.2-2Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16.5 11 12 15l-4.5-4"></path>
|
||||
</template>
|
||||
|
||||
<!-- embed -->
|
||||
<template v-else-if="commandId === 'embed'">
|
||||
<!-- embed/html/link/iframe -->
|
||||
<template v-else-if="['html', 'link', 'iframe'].includes(commandId)">
|
||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm.5 16c0 .3-.2.5-.5.5H5c-.3 0-.5-.2-.5-.5V9.8l4.7-5.3H19c.3 0 .5.2.5.5v14zm-6-9.5L16 12l-2.5 2.8 1.1 1L18 12l-3.5-3.5-1 1zm-3 0l-1-1L6 12l3.5 3.8 1.1-1L8 12l2.5-2.5z"></path>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -35,9 +35,9 @@ 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 ProseHtml from './ProseHtml.vue'
|
||||
import ContentMarkdownCalloutEditor from './ContentMarkdownCalloutEditor.vue'
|
||||
import ContentMarkdownToggleEditor from './ContentMarkdownToggleEditor.vue'
|
||||
|
||||
@@ -253,13 +253,13 @@ const isMarkdownBlockStart = (line) => {
|
||||
trimmedLine === ':::video' ||
|
||||
trimmedLine === ':::audio' ||
|
||||
trimmedLine === ':::file' ||
|
||||
trimmedLine === ':::embed' ||
|
||||
trimmedLine === ':::link' ||
|
||||
trimmedLine === ':::iframe' ||
|
||||
trimmedLine === ':::html' ||
|
||||
trimmedLine.startsWith(':::callout') ||
|
||||
trimmedLine.startsWith(':::toggle') ||
|
||||
trimmedLine.startsWith('```') ||
|
||||
trimmedLine === '---' ||
|
||||
isStandaloneUrlLine(trimmedLine) ||
|
||||
isIframeEmbedMarkup(trimmedLine) ||
|
||||
/^(#{1,6})\s+(.+)$/.test(trimmedLine) ||
|
||||
trimmedLine.startsWith('> ') ||
|
||||
/^- /.test(trimmedLine) ||
|
||||
@@ -689,11 +689,12 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::embed') {
|
||||
if ([':::link', ':::iframe', ':::html'].includes(trimmedLine)) {
|
||||
const startLine = index
|
||||
const blockType = trimmedLine.replace(':::', '')
|
||||
const { contentLines, endLine, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
|
||||
createBlock(blockType, '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }),
|
||||
startLine,
|
||||
endLine
|
||||
))
|
||||
@@ -711,28 +712,6 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isStandaloneUrlLine(trimmedLine)) {
|
||||
const startLine = index
|
||||
blocks.push(attachSourceRange(
|
||||
createBlock('embed', '', null, `block-${blocks.length}`, { url: trimmedLine }),
|
||||
startLine,
|
||||
startLine
|
||||
))
|
||||
index += 1
|
||||
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) {
|
||||
@@ -3282,27 +3261,29 @@ onBeforeUnmount(() => {
|
||||
:size="block.meta.size"
|
||||
/>
|
||||
<section
|
||||
v-else-if="block.type === 'embed' && interactive"
|
||||
v-else-if="['link', 'iframe', 'html'].includes(block.type) && interactive"
|
||||
class="content-markdown-renderer__embed-live group relative mb-8 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
|
||||
:data-source-line="block.meta.startLine"
|
||||
data-preview-card-block="true"
|
||||
tabindex="0"
|
||||
role="group"
|
||||
aria-label="HTML 블록"
|
||||
:aria-label="block.type === 'link' ? '링크 블록' : block.type === 'iframe' ? 'iframe 블록' : '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="HTML 블록 삭제"
|
||||
:aria-label="block.type === 'link' ? '링크 블록 삭제' : block.type === 'iframe' ? 'iframe 블록 삭제' : 'HTML 블록 삭제'"
|
||||
@click.stop="deletePreviewCardBlock(block)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<ProseEmbed :url="block.url" />
|
||||
<ProseHtml v-if="block.type === 'html'" :html="block.url" />
|
||||
<ProseEmbed v-else :url="block.url" />
|
||||
</section>
|
||||
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
||||
<ProseHtml v-else-if="block.type === 'html'" :html="block.url" />
|
||||
<ProseEmbed v-else-if="block.type === 'link' || block.type === 'iframe'" :url="block.url" />
|
||||
<div
|
||||
v-else-if="block.type === 'gallery'"
|
||||
class="content-markdown-renderer__gallery group relative my-8 flex flex-col gap-2 rounded-[12px] outline-none transition-shadow focus-within:ring-2 focus-within:ring-[var(--site-accent)] focus-within:ring-offset-2 focus-visible:ring-2 focus-visible:ring-[var(--site-accent)] focus-visible:ring-offset-2"
|
||||
|
||||
54
components/content/ProseHtml.vue
Normal file
54
components/content/ProseHtml.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
import { sanitizeHtmlBlock } from '../../lib/markdown-html-block.js'
|
||||
|
||||
const props = defineProps({
|
||||
html: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const sanitizedHtml = computed(() => sanitizeHtmlBlock(props.html))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="sanitizedHtml"
|
||||
class="prose-html prose-html-block mb-8 overflow-x-auto text-base leading-7 text-[var(--site-text)]"
|
||||
v-html="sanitizedHtml"
|
||||
/>
|
||||
<p v-else class="prose-html prose-html-block mb-8 text-sm font-semibold text-[var(--site-muted)]">
|
||||
표시할 수 있는 HTML이 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prose-html :deep(table) {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prose-html :deep(th),
|
||||
.prose-html :deep(td) {
|
||||
border: 1px solid var(--site-line);
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.prose-html :deep(th) {
|
||||
background: color-mix(in srgb, var(--site-text) 6%, transparent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.prose-html :deep(p) {
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.prose-html :deep(a) {
|
||||
color: var(--site-accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user