게시물 HTML iframe 블록 추가

This commit is contained in:
2026-06-17 09:52:56 +09:00
parent 6c4064d8b5
commit d41cb71d40
17 changed files with 211 additions and 45 deletions

View File

@@ -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>

View File

@@ -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=&quot;...&quot;></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>

View File

@@ -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)
}

View File

@@ -1154,8 +1154,8 @@ const onBlockPanelAddGalleryImages = () => {
}
/**
* 블록 패널에서 임베드 URL을 수정한다.
* @param {string} url - 임베드 URL
* 블록 패널에서 HTML 임베드 값을 수정한다.
* @param {string} url - HTML 임베드 값
* @returns {void}
*/
const onBlockPanelUpdateEmbedUrl = (url) => {

View File

@@ -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)"
>
삭제

View File

@@ -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"

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.106
- 글쓰기 소스 모드에서 텍스트 색상 적용 후 화면이 아래로 이동하던 문제를 보정했다.
- 게시물 본문에 외부 iframe HTML 코드를 붙여넣으면 HTML 블록으로 렌더링할 수 있게 했다.
- 기존 임베드 블록 UI는 작성자 기준에 맞춰 HTML로 표시한다.
## v1.5.105
- 글쓰기에서 일반 텍스트를 선택하면 오른쪽 패널에서 글자색을 지정할 수 있게 했다.

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.5.105에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.5.106에서 `npm run lint`, `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형
@@ -16,6 +16,13 @@
## 로컬 개발
### v1.5.106 참고
- 추가 DB 마이그레이션은 없다.
- 게시물 글쓰기 소스 모드에서 텍스트 중간 범위를 선택하고 색상 적용 후 화면 스크롤이 유지되는지 확인한다.
- `/html` 또는 `/iframe` 슬래시 명령으로 HTML 블록을 찾을 수 있는지 확인한다.
- `<iframe src="https://..."></iframe>` 코드가 공개 본문과 라이브 모드에서 iframe으로 렌더링되는지 확인한다.
### v1.5.105 참고
- 추가 DB 마이그레이션은 없다.

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-06-17 v1.5.106 — 게시물 HTML 블록은 iframe만 허용한다
외부 자료 공유 코드는 작성자에게는 HTML 블록처럼 보이지만, 게시물 본문에서 임의 HTML 전체를 실행하면 보안과 레이아웃 영향 범위가 커진다. 그래서 기존 임베드 저장 타입은 호환을 위해 유지하되 UI 라벨은 HTML로 바꾸고, 실제 HTML 처리는 iframe 코드의 안전한 속성만 추출해 sandbox iframe으로 렌더링하는 범위로 제한한다.
## 2026-06-16 v1.5.105 — 텍스트 색상은 제한된 인라인 토큰으로 저장한다
게시물 본문에서 중요한 단어나 덜 중요한 단어를 색으로 구분하려면 작성 UI에서는 오른쪽 패널 선택 흐름이 자연스럽지만, 저장 형식은 임의 HTML style보다 검증 가능한 마크다운 확장 토큰이 안전하다. 따라서 선택 텍스트 색상은 `::red[텍스트]`처럼 제한된 인라인 토큰으로 저장하고, 렌더러는 `gray`, `blue`, `green`, `yellow`, `red`, `purple` 프리셋만 해석한다. 이렇게 하면 공개 화면 다크 모드 색상 보정과 라이브 편집 직렬화를 한 곳에서 관리할 수 있다.

View File

@@ -29,7 +29,8 @@
| lib/external-favicon-url.js | 외부 URL 호스트 기준 Google `s2/favicons` 썸네일 URL 생성(내부 경로는 빈 문자열) |
| lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` |
| lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 |
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·임베드·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
| lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·HTML 임베드·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 코드 fenced 블록 여닫는 펜스 쌍 판별 |
| lib/markdown-iframe-embed.js | 게시물 본문 HTML 블록의 iframe 코드 판별과 안전 속성 추출 |
| lib/markdown-callout.js | 인용 막대 색상·콜아웃 배경색 옵션, 선언 줄 파싱·직렬화 |
| lib/brand-color.js | 사이트 브랜드 컬러 기본값·hex 검증·정규화 |
| lib/markdown-image.js | 이미지 마크다운 직렬화·파싱, 단독 이미지 URL 판별 |
@@ -110,8 +111,8 @@
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시, 업로드 완료 후 목록 갱신 및 수동 선택 삽입), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·임베드·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |
@@ -149,7 +150,7 @@
| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) |
| components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 |
| components/content/ProseHeaderCard.vue | 헤더 카드 |
| components/content/ProseEmbed.vue | YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 기타 `http(s)` 외부 링크 |
| components/content/ProseEmbed.vue | YouTube 영상 iframe, X/Twitter 소셜 iframe, Mastodon `/embed` iframe, 제한된 사용자 iframe HTML, 기타 `http(s)` 외부 링크 |
## 관리자 페이지

View File

@@ -83,7 +83,7 @@
- 공개 게시물 본문은 콘텐츠 타입별 컴포넌트로 분리해 추후 스타일 변경이 쉽도록 구성
- 표준 마크다운 표(`| 헤더 | ... |`, `| --- | ... |`)는 본문에서 가로 스크롤 가능한 HTML table로 렌더링한다. 정렬 구분선(`:---`, `:---:`, `---:`)은 각각 좌/중앙/우 정렬로 반영한다.
- 인용문(`>`)은 왼쪽 세로 막대형 기본 스타일로 표시한다. 기본 인용 텍스트는 라이트·다크 모드 모두 사이트 본문 텍스트 색상(`--site-text`)을 따른다. 첫 줄 옵션 `> [!bg=blue]` 또는 `> {bg=blue}`는 인용 막대 색상으로 반영하며, 지원 값은 콜아웃과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`이다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 관리자 Markdown-first 글쓰기의 오른쪽 블록 설정 패널은 인용·콜아웃·코드 블록·토글 설정과 선택 텍스트 색상 설정을 지원한다. 콜아웃은 제목·아이콘 표시 여부·아이콘·배경색, 코드 블록은 언어·줄번호 표시 여부, 토글은 기본 펼침·닫힘 상태를 선언 줄에 저장한다. 일반 텍스트 범위를 선택하면 텍스트 색상 패널이 열리고 회색·파랑·초록·노랑·빨강·보라 인라인 색상을 적용한다. 콜아웃 아이콘은 라이브·공개 화면 모두 왼쪽 상단에 배치하고, 아이콘·제목 헤더 아래에 본문을 줄바꿈해 표시한다. 아이콘 미사용 시 자리 표시자를 남기지 않는다. 라이브 문단에서는 `>` 입력으로 인용, ``` Enter로 코드 블록, `!!!` Enter로 콜아웃을 만들 수 있고, `/표` 또는 `/table` 슬래시 명령으로 기본 3열 표 마크다운을 삽입한다. `/html` 또는 `/iframe` 슬래시 명령은 외부 URL 또는 iframe HTML을 넣는 HTML 블록을 찾는다. 소스·라이브 모드 모두 `Cmd/Ctrl+K`로 링크 마크다운을 삽입한다. 라이브 코드·인용·콜아웃·토글 블록은 맨 위/맨 아래 방향키로 외부 기본 문단을 만들며 빠져나올 수 있고, 인용 첫 글자 앞 Backspace는 일반 문단으로 되돌린다. 한글 등 IME 조합 입력 중에는 줄바꿈 직후 블록 판별이 일시적으로 비어도 마지막 블록 컨텍스트를 유지해 설정 패널이 닫히지 않게 한다.
- 게시물 상세의 오른쪽 사이드바는 데스크톱에서 블로그 소개·Follow·추천 사이트 대신 본문 H1~H3 제목 기반 목차를 최상단에 표시한다. 목차는 별도 최대 높이와 내부 스크롤을 두지 않고 전체 목록이 먼저 펼쳐지며, 왼쪽 기준선과 브랜드 컬러 활성 항목 표시선을 둔다. 목차 항목은 최소 24px 높이와 세로 중앙 정렬을 사용하며, 2줄 이상 제목에서는 항목 높이에 맞춰 활성 표시선도 함께 늘어난다. 활성 표시선은 항목의 가상 요소 배경 막대로 그려 텍스트 시작점이 밀리지 않게 한다. 목차 링크는 본문 제목에 부여된 앵커 ID로 부드럽게 이동하며, 고정 상단 헤더 높이와 여백을 반영해 제목이 화면 밖에 걸리지 않게 한다. 본문 스크롤 중에는 현재 제목에 해당하는 목차 항목을 강조하고, 목차 항목이 많으면 오른쪽 사이드바 스크롤이 활성 항목을 따라간다. 본문 제목이 없으면 목차 없음 문구를 표시한다. 오른쪽 사이드바가 본문 아래로 내려가는 모바일 폭에서는 목차를 숨긴다. 게시물 상세에서는 일반 오른쪽 사이드 광고 대신 게시물 사이드 광고 코드를 목차 아래에 표시하고, 왼쪽 사이드바에는 게시물 광고를 표시하지 않는다. 게시물 상세 목차와 게시물 사이드 광고 슬롯은 하단 구분선을 표시하지 않는다.
- 제목 우측 공유 버튼을 누르면 게시물 공유 모달을 연다.
- 로그인 회원 ID가 게시물 `author_id`와 같으면 제목 우측 공유 버튼 옆에 수정 아이콘을 표시하며, 클릭 시 `/admin/posts/:id` 편집 화면을 새 탭으로 연다.
@@ -214,7 +214,7 @@ components/content/
├── ProseBookmark.vue # 북마크 카드(썸네일·제목·도메인)
├── ProseSignup.vue # 회원가입/뉴스레터 CTA 카드
├── ProseHeaderCard.vue # Header 카드 (Simple/Wide/Full-width/Split)
└── ProseEmbed.vue # Embeds (YouTube iframe, Twitter/X iframe, 기타 링크)
└── ProseEmbed.vue # HTML embeds (YouTube iframe, Twitter/X iframe, 사용자 iframe, 기타 링크)
```
### 공개 본문 스타일 가이드(Thred 기준)
@@ -259,7 +259,7 @@ components/content/
- Toggle: `:::toggle 제목` ~ `:::`
- Bookmark: `:::bookmark` ~ `:::` (본문은 `url=`, `title=`, `description=`, `thumbnail=` 키값 또는 첫 줄 URL·이어지는 제목/설명 줄)
- Signup: `:::signup` ~ `:::` (선택: `title=`, `description=`, `button=`, `placeholder=`)
- Embed: 단독 `http(s)` URL 한 줄(기존 `:::embed` ~ `:::`도 렌더링 호환). YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외는 외부 링크 카드
- HTML: 단독 `http(s)` URL 한 줄 또는 iframe HTML(기존 `:::embed` ~ `:::`도 렌더링 호환). YouTube·YouTube Shorts URL은 iframe, `twitter.com`·`x.com` 게시물 URL은 X 공식 embed iframe, 그 외 URL은 외부 링크 카드
- 렌더링: `ProseCallout.vue`, `ProseToggle.vue`, `ProseBookmark.vue`, `ProseSignup.vue`, `ProseEmbed.vue`
---
@@ -665,8 +665,8 @@ components/content/
- 툴바 `이미지`·`갤러리`는 미디어 모달을 연다. 모달 기본 탭은 **미디어 라이브러리**이며 **업로드** 탭에서 드래그·파일 선택 후 라이브러리 목록을 갱신한다. 업로드 중에는 추가 드롭·파일 선택과 모달 닫기를 막고 로딩 스피너를 표시하며, 업로드 완료 후에는 모달을 자동으로 닫거나 본문에 자동 삽입하지 않는다. 본문 삽입은 라이브러리 목록에서 파일을 직접 선택한 뒤 `삽입` 버튼을 눌렀을 때만 실행한다.
- 미디어 라이브러리에서 단일 이미지를 선택하면 `![alt](url)` 형식으로 삽입한다.
- 미디어 라이브러리에서 여러 이미지를 선택하면 `:::gallery` fenced block으로 삽입한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL 임베드 줄, 기존 `:::embed`, 인용문, `:::callout`, 코드 fenced 블록, `:::toggle` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 라이브 모드 멀티라인 편집기는 실제 커서가 있는 원본 줄을 패널 상태에 반영하며, 커서가 지원 블록 밖으로 이동하면 직전 코드·콜아웃·인용 설정 패널을 닫는다. 코드 fenced 블록 판별은 위에서 아래로 여는 펜스와 닫는 펜스를 짝지어 처리해, 닫는 ` ``` ` 줄을 다음 코드 블록 시작으로 오인하지 않는다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), 임베드(URL), 인용 배경색, 콜아웃 제목·아이콘·배경색, 코드 언어·줄번호, 토글 기본 펼침 상태, 선택 텍스트 색상. `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
- 작성 모드에서 커서가 이미지 마크다운 줄, `:::gallery`, 단독 URL HTML 블록 줄, 단독 iframe HTML 줄, 기존 `:::embed`, 인용문, `:::callout`, 코드 fenced 블록, `:::toggle` 블록 안에 있고 textarea(또는 블록 패널)에 포커스가 있으면 게시물 설정 사이드바(420px) 위에 **블록 설정 패널**(`AdminEditorBlockPanel`)이 오른쪽에서 슬라이드 인한다. 본문·패널 바깥을 클릭하면 슬라이드 아웃한다. 라이브 모드 멀티라인 편집기는 실제 커서가 있는 원본 줄을 패널 상태에 반영하며, 커서가 지원 블록 밖으로 이동하면 직전 코드·콜아웃·인용 설정 패널을 닫는다. 코드 fenced 블록 판별은 위에서 아래로 여는 펜스와 닫는 펜스를 짝지어 처리해, 닫는 ` ``` ` 줄을 다음 코드 블록 시작으로 오인하지 않는다. 갤러리 이미지 추가 미디어 모달을 여는 동안에는 활성 갤러리 컨텍스트와 패널 상태를 유지한다.
- 블록 설정 패널: 이미지·갤러리(캡션, **파일명을 캡션으로 사용** 토글·기본 끔, URL, 갤러리 순서·삭제·추가), HTML(URL 또는 iframe), 인용 배경색, 콜아웃 제목·아이콘·배경색, 코드 언어·줄번호, 토글 기본 펼침 상태, 선택 텍스트 색상. `AdminMarkdownEditor`는 `block-panel` 이벤트로 상태를 `AdminPostForm`에 전달한다.
- 미디어 라이브러리 갤러리 다중 선택 시 선택 항목은 **주황(`#ff7a00`) 굵은 테두리**로 표시한다.
- 옵시디언식 토큰 숨김/백스페이스 복원 Live Preview는 후속 단계로 둔다.
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
@@ -727,9 +727,10 @@ components/content/
- `emoji=none`이면 공개 렌더러에서 이모지를 숨긴다.
- 콜아웃 배경 프리셋은 인용 블록과 같은 `gray`, `blue`, `green`, `yellow`, `red`, `purple`를 지원한다.
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다. 라이브 모드에서는 제목·본문을 인라인 편집하며, chevron으로 펼침·접힘 시 본문이 애니메이션된다.
- 임베드 블록은 이미지 파일 URL을 제외한 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다.
- 기존 `:::embed` fenced block은 이전 콘텐츠 호환을 위해 계속 파싱·렌더링한다.
- 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 임베드 블록은 URL 입력 카드 없이 즉시 실제 임베드 프리뷰로 표시된다. 임베드·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다.
- HTML 블록은 이미지 파일 URL을 제외한 단독 `http(s)` URL 한 줄을 기본 저장 형식으로 사용한다.
- 기존 `:::embed` fenced block은 이전 콘텐츠 호환과 여러 줄 iframe 코드 저장을 위해 계속 파싱·렌더링한다.
- 게시물 본문 HTML 블록은 임의 HTML 실행이 아니라 iframe HTML만 허용한다. iframe 코드는 `src`, `title`, `allow`, `width`, `height`, `allowfullscreen` 등 제한된 속성만 추출해 sandbox iframe으로 렌더링한다.
- 관리자 Markdown-first 에디터의 라이브/스타일 모드에서 HTML 블록은 URL 입력 카드 없이 즉시 실제 프리뷰로 표시된다. HTML·비디오·오디오·파일 프리뷰 카드는 hover/focus 시 우측 상단 삭제 버튼을 표시한다. 블록 래퍼에 포커스한 상태에서 `Backspace`·`Delete`·`Ctrl/Cmd+Shift+K`로 삭제하고, `Enter`로 아래 빈 줄을 추가하며, `ArrowUp`·`ArrowDown`은 브라우저 스크롤 대신 이전/다음 편집 줄로 이동한다.
- 라이브/스타일 모드에서 제목 블록 Enter는 현재 제목 내용을 저장한 뒤 바로 아래 빈 문단을 추가하고, 원문 마크다운 편집 상태로 전환하지 않는다.
- 게시물 작성 화면 상단 제목 입력 후 Enter는 현재 에디터 모드를 유지한 채 본문 첫 줄(마크다운 첫 줄이 제목이면 그 다음 줄)로 포커스를 옮긴다.
- 소스 모드 라인 번호는 논리 줄 수를 표시하되, 긴 문장 자동 줄바꿈으로 textarea의 한 줄 높이가 늘어나면 라인 번호 칸도 같은 높이로 맞춘다.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.5.106
- 게시물 글쓰기: 소스 모드 텍스트 색상 적용 후 textarea와 에디터 스크롤 위치를 함께 복원하도록 수정.
- 게시물 글쓰기: 기존 임베드 UI 라벨을 HTML로 변경하고 `/html`, `/iframe` 검색어로 찾을 수 있도록 수정.
- 게시물 본문: 단독 iframe HTML 또는 `:::embed` 블록 안 iframe HTML을 안전 속성 기반 iframe으로 렌더링하도록 추가.
## v1.5.105
- 게시물 글쓰기: 일반 텍스트 선택 시 오른쪽 블록 설정 패널에 텍스트 색상 선택 패널이 열리도록 추가.

View File

@@ -2,6 +2,7 @@ import { isImageUrl, parseImageMarkdownLine } from './markdown-image.js'
import { CALLOUT_BACKGROUND_OPTIONS, QUOTE_BACKGROUND_OPTIONS, parseCalloutOptions } from './markdown-callout.js'
import { parseCodeFenceLine } from './markdown-code-block.js'
import { parseToggleOpenerLine } from './markdown-toggle.js'
import { isIframeEmbedMarkup } from './markdown-iframe-embed.js'
/**
* fenced 블록 시작 줄 인덱스를 찾는다.
@@ -198,7 +199,7 @@ const resolveGalleryBlock = (lines, currentLine) => {
const resolveEmbedBlock = (lines, currentLine) => {
const standaloneUrl = String(lines[currentLine] || '').trim()
if (isStandaloneUrlLine(standaloneUrl) && !isStandaloneImageUrlLine(standaloneUrl)) {
if ((isStandaloneUrlLine(standaloneUrl) && !isStandaloneImageUrlLine(standaloneUrl)) || isIframeEmbedMarkup(standaloneUrl)) {
return {
kind: 'embed',
startLine: currentLine,

View File

@@ -0,0 +1,87 @@
/**
* iframe 속성 문자열을 객체로 변환한다.
* @param {string} value - iframe 여는 태그의 속성 문자열
* @returns {Record<string, string>} 속성 객체
*/
const parseIframeAttributes = (value) => {
const attributes = {}
const attributePattern = /([:\w-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g
let match = attributePattern.exec(String(value || ''))
while (match) {
attributes[match[1].toLowerCase()] = match[3] ?? match[4] ?? match[5] ?? ''
match = attributePattern.exec(String(value || ''))
}
return attributes
}
/**
* 외부 iframe src로 사용할 수 있는 URL인지 확인한다.
* @param {string} value - 검사할 URL
* @returns {boolean} 허용 여부
*/
const isSafeIframeSrc = (value) => {
try {
const parsedUrl = new URL(value)
return ['http:', 'https:'].includes(parsedUrl.protocol)
} catch {
return false
}
}
/**
* iframe 숫자 속성을 정리한다.
* @param {string} value - 원본 값
* @returns {string} 정리된 값
*/
const sanitizeSizeValue = (value) => {
const text = String(value || '').trim()
return /^\d{2,5}$/.test(text) ? text : ''
}
/**
* iframe allow 속성을 정리한다.
* @param {string} value - 원본 값
* @returns {string} 정리된 값
*/
const sanitizeAllowValue = (value) => String(value || '')
.replace(/[^\w\s;*.:/-]/g, '')
.trim()
/**
* iframe HTML 코드인지 확인한다.
* @param {string} value - 검사할 문자열
* @returns {boolean} iframe 코드 여부
*/
export const isIframeEmbedMarkup = (value) => /^<iframe\b[\s\S]*?(?:<\/iframe>\s*)?$/i.test(String(value || '').trim())
/**
* iframe HTML 코드에서 렌더링 가능한 정보만 추출한다.
* @param {string} value - iframe HTML 코드
* @returns {{ src: string, title: string, allow: string, width: string, height: string, allowFullscreen: boolean }|null} iframe 정보
*/
export const parseIframeEmbedMarkup = (value) => {
const source = String(value || '').trim()
const opener = source.match(/^<iframe\b([^>]*)>/i)
if (!opener) {
return null
}
const attributes = parseIframeAttributes(opener[1])
const src = attributes.src || ''
if (!isSafeIframeSrc(src)) {
return null
}
return {
src,
title: attributes.title || 'Embedded content',
allow: sanitizeAllowValue(attributes.allow || 'fullscreen; picture-in-picture; encrypted-media; web-share'),
width: sanitizeSizeValue(attributes.width),
height: sanitizeSizeValue(attributes.height),
allowFullscreen: /\ballowfullscreen\b/i.test(opener[1])
}
}

View File

@@ -117,9 +117,9 @@ export const MARKDOWN_SLASH_COMMANDS = [
},
{
id: 'embed',
label: '임베드',
description: 'YouTube·X 등 외부 URL 한 줄',
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브'],
label: 'HTML',
description: 'URL 또는 iframe HTML',
keywords: ['html', 'iframe', 'embed', 'youtube', 'link', '임베드', '유튜브'],
action: 'lines',
lines: ['https://']
},

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.5.105",
"version": "1.5.106",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.5.105",
"version": "1.5.106",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.5.105",
"version": "1.5.106",
"private": true,
"type": "module",
"imports": {