v1.2.1: 블록 설정 패널·이미지 alt 토글 및 포커스 수정
게시물 설정 사이드바 오버레이로 이미지·갤러리·임베드를 편집하고, 파일명 alt 토글과 패널 입력 중 닫힘 문제를 해결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
124
lib/markdown-block-context.js
Normal file
124
lib/markdown-block-context.js
Normal file
@@ -0,0 +1,124 @@
|
||||
import { parseImageMarkdownLine } from './markdown-image.js'
|
||||
|
||||
/**
|
||||
* fenced 블록 시작 줄 인덱스를 찾는다.
|
||||
* @param {string[]} lines - 본문 줄 목록
|
||||
* @param {number} currentLine - 현재 줄
|
||||
* @param {string} opener - 시작 토큰
|
||||
* @returns {number} 시작 줄 또는 -1
|
||||
*/
|
||||
const findFencedBlockStart = (lines, currentLine, opener) => {
|
||||
for (let index = currentLine; index >= 0; index -= 1) {
|
||||
if ((lines[index] || '').trim() === opener) {
|
||||
return index
|
||||
}
|
||||
|
||||
if ((lines[index] || '').trim() === ':::') {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* fenced 블록 종료 줄 인덱스를 찾는다.
|
||||
* @param {string[]} lines - 본문 줄 목록
|
||||
* @param {number} startLine - 시작 줄
|
||||
* @returns {number} 종료 줄 또는 -1
|
||||
*/
|
||||
const findFencedBlockEnd = (lines, startLine) => {
|
||||
for (let index = startLine + 1; index < lines.length; index += 1) {
|
||||
if ((lines[index] || '').trim() === ':::') {
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 fenced 블록을 파싱한다.
|
||||
* @param {string[]} lines - 본문 줄 목록
|
||||
* @param {number} currentLine - 현재 줄
|
||||
* @returns {{ kind: 'gallery', startLine: number, endLine: number, images: Array<Object> }|null}
|
||||
*/
|
||||
const resolveGalleryBlock = (lines, currentLine) => {
|
||||
const galleryStart = findFencedBlockStart(lines, currentLine, ':::gallery')
|
||||
|
||||
if (galleryStart === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const galleryEnd = findFencedBlockEnd(lines, galleryStart)
|
||||
|
||||
if (galleryEnd === -1 || currentLine > galleryEnd) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'gallery',
|
||||
startLine: galleryStart,
|
||||
endLine: galleryEnd,
|
||||
images: lines
|
||||
.slice(galleryStart + 1, galleryEnd)
|
||||
.map(parseImageMarkdownLine)
|
||||
.filter(Boolean)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드 fenced 블록을 파싱한다.
|
||||
* @param {string[]} lines - 본문 줄 목록
|
||||
* @param {number} currentLine - 현재 줄
|
||||
* @returns {{ kind: 'embed', startLine: number, endLine: number, url: string }|null}
|
||||
*/
|
||||
const resolveEmbedBlock = (lines, currentLine) => {
|
||||
const embedStart = findFencedBlockStart(lines, currentLine, ':::embed')
|
||||
|
||||
if (embedStart === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const embedEnd = findFencedBlockEnd(lines, embedStart)
|
||||
|
||||
if (embedEnd === -1 || currentLine > embedEnd) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'embed',
|
||||
startLine: embedStart,
|
||||
endLine: embedEnd,
|
||||
url: lines.slice(embedStart + 1, embedEnd).join('\n').trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커서 줄 기준 활성 블록 컨텍스트를 반환한다.
|
||||
* @param {string} markdown - 본문 마크다운
|
||||
* @param {number} lineIndex - 현재 줄(0-based)
|
||||
* @returns {Object|null} 블록 컨텍스트
|
||||
*/
|
||||
export const resolveActiveBlockContext = (markdown, lineIndex) => {
|
||||
const lines = String(markdown || '').split('\n')
|
||||
const currentLine = Math.min(Math.max(0, lineIndex), Math.max(0, lines.length - 1))
|
||||
const activeImage = parseImageMarkdownLine(lines[currentLine] || '')
|
||||
|
||||
if (activeImage) {
|
||||
return {
|
||||
kind: 'image',
|
||||
startLine: currentLine,
|
||||
endLine: currentLine,
|
||||
images: [activeImage]
|
||||
}
|
||||
}
|
||||
|
||||
const gallery = resolveGalleryBlock(lines, currentLine)
|
||||
|
||||
if (gallery) {
|
||||
return gallery
|
||||
}
|
||||
|
||||
return resolveEmbedBlock(lines, currentLine)
|
||||
}
|
||||
100
lib/markdown-image.js
Normal file
100
lib/markdown-image.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/** @type {RegExp} 이미지 마크다운 한 줄 */
|
||||
const IMAGE_MARKDOWN_LINE_RE = /^!\[(.*?)\]\((\S+?)(?:\s+"((?:[^"\\]|\\.)*)")?\)(?:\{width=(regular|wide|full)\})?$/
|
||||
|
||||
/**
|
||||
* 캡션 문자열 이스케이프 해제
|
||||
* @param {string} value - 이스케이프된 캡션
|
||||
* @returns {string} 캡션
|
||||
*/
|
||||
const unescapeImageCaption = (value) => String(value || '').replace(/\\"/g, '"')
|
||||
|
||||
/**
|
||||
* 캡션 문자열 이스케이프
|
||||
* @param {string} value - 캡션
|
||||
* @returns {string} 이스케이프된 캡션
|
||||
*/
|
||||
const escapeImageCaption = (value) => String(value || '').replace(/"/g, '\\"')
|
||||
|
||||
/**
|
||||
* URL에서 기본 대체 텍스트(파일명) 추출
|
||||
* @param {string} url - 이미지 URL
|
||||
* @returns {string} 파일명 기반 라벨
|
||||
*/
|
||||
export const getImageDefaultAltLabel = (url) => {
|
||||
const raw = String(url || '').trim()
|
||||
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const pathname = new URL(raw, 'https://sori.studio').pathname
|
||||
return decodeURIComponent(pathname.split('/').filter(Boolean).pop() || '')
|
||||
} catch {
|
||||
const withoutQuery = raw.split('?')[0].split('#')[0]
|
||||
return decodeURIComponent(withoutQuery.split('/').filter(Boolean).pop() || '')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 한 줄 파싱
|
||||
* @param {string} line - 마크다운 줄
|
||||
* @returns {{ url: string, width: string, caption: string, useAlt: boolean }|null}
|
||||
*/
|
||||
export const parseImageMarkdownLine = (line) => {
|
||||
const match = String(line || '').trim().match(IMAGE_MARKDOWN_LINE_RE)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const alt = match[1] || ''
|
||||
|
||||
return {
|
||||
url: match[2] || '',
|
||||
caption: unescapeImageCaption(match[3]),
|
||||
width: match[4] || 'regular',
|
||||
/** true이면 대체 텍스트로 URL 파일명을 사용한다 */
|
||||
useAlt: Boolean(alt)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 한 줄 생성
|
||||
* @param {{ url: string, caption?: string, width?: string, useAlt?: boolean }} image - 이미지 정보
|
||||
* @returns {string} 마크다운 줄
|
||||
*/
|
||||
export const serializeImageMarkdown = (image) => {
|
||||
const url = String(image.url || '').trim()
|
||||
|
||||
if (!url) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const alt = image.useAlt === true ? getImageDefaultAltLabel(url) : ''
|
||||
const caption = String(image.caption ?? '').trim()
|
||||
const titlePart = caption ? ` "${escapeImageCaption(caption)}"` : ''
|
||||
const width = image.width && image.width !== 'regular' ? `{width=${image.width}}` : ''
|
||||
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 렌더용 img alt 텍스트
|
||||
* @param {{ url?: string, useAlt?: boolean }} image - 이미지 정보
|
||||
* @returns {string} alt 속성값
|
||||
*/
|
||||
export const getImageAltAttribute = (image) => {
|
||||
if (!image?.useAlt) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return getImageDefaultAltLabel(image.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 공개 렌더용 캡션(표시용 figcaption)
|
||||
* @param {{ caption?: string }} image - 이미지 정보
|
||||
* @returns {string} 캡션
|
||||
*/
|
||||
export const getImageCaption = (image) => String(image?.caption || '').trim()
|
||||
Reference in New Issue
Block a user