@lumir-company/editor 0.4.22 → 0.4.25

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\r\n * @lumir-company/editor - Link Preview API Handler\r\n *\r\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\r\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\r\n *\r\n * Next.js App Router 사용 예시:\r\n * ```ts\r\n * // src/app/api/link-preview/route.ts\r\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\r\n * ```\r\n */\r\n\r\nexport interface LinkMetadata {\r\n url: string;\r\n title: string;\r\n description?: string;\r\n image?: string;\r\n domain: string;\r\n}\r\n\r\nfunction extractDomain(url: string): string {\r\n try {\r\n return new URL(url).hostname.replace(/^www\\./, \"\");\r\n } catch {\r\n return url;\r\n }\r\n}\r\n\r\nfunction decodeHtmlEntity(str: string): string {\r\n return str\r\n .replace(/&amp;/g, \"&\")\r\n .replace(/&lt;/g, \"<\")\r\n .replace(/&gt;/g, \">\")\r\n .replace(/&quot;/g, '\"')\r\n .replace(/&#39;/g, \"'\")\r\n .replace(/&nbsp;/g, \" \")\r\n .replace(/&#x27;/g, \"'\")\r\n .replace(/&#x2F;/g, \"/\");\r\n}\r\n\r\nfunction getMetaContent(\r\n html: string,\r\n property: string,\r\n name?: string\r\n): string | null {\r\n const patterns = [\r\n new RegExp(\r\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\r\n \"i\"\r\n ),\r\n ];\r\n\r\n if (name) {\r\n patterns.push(\r\n new RegExp(\r\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\r\n \"i\"\r\n )\r\n );\r\n }\r\n\r\n for (const pattern of patterns) {\r\n const match = html.match(pattern);\r\n if (match?.[1]) return match[1].trim();\r\n }\r\n\r\n return null;\r\n}\r\n\r\n/**\r\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\r\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\r\n */\r\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\r\n const domain = extractDomain(baseUrl);\r\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\r\n\r\n const ogTitle = getMetaContent(html, \"og:title\");\r\n const ogDescription = getMetaContent(html, \"og:description\");\r\n const ogImage = getMetaContent(html, \"og:image\");\r\n const ogUrl = getMetaContent(html, \"og:url\");\r\n\r\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\r\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\r\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\r\n\r\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\r\n const descriptionMatch = html.match(\r\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\r\n );\r\n\r\n if (ogTitle) {\r\n metadata.title = decodeHtmlEntity(ogTitle);\r\n } else if (twitterTitle) {\r\n metadata.title = decodeHtmlEntity(twitterTitle);\r\n } else if (titleMatch?.[1]) {\r\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\r\n }\r\n\r\n if (ogDescription) {\r\n metadata.description = decodeHtmlEntity(ogDescription);\r\n } else if (twitterDescription) {\r\n metadata.description = decodeHtmlEntity(twitterDescription);\r\n } else if (descriptionMatch?.[1]) {\r\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\r\n }\r\n\r\n let imageUrl: string | undefined;\r\n if (ogImage) {\r\n imageUrl = ogImage;\r\n } else if (twitterImage) {\r\n imageUrl = twitterImage;\r\n }\r\n\r\n if (imageUrl) {\r\n imageUrl = decodeHtmlEntity(imageUrl);\r\n if (imageUrl.trim()) {\r\n try {\r\n metadata.image = new URL(imageUrl, baseUrl).toString();\r\n } catch {\r\n metadata.image = undefined;\r\n }\r\n }\r\n }\r\n\r\n if (ogUrl) {\r\n try {\r\n metadata.url = new URL(ogUrl, baseUrl).toString();\r\n } catch {\r\n // keep original URL\r\n }\r\n }\r\n\r\n return metadata;\r\n}\r\n\r\n/**\r\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\r\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\r\n */\r\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\r\n const targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n throw new Error(\"Only http and https URLs are allowed\");\r\n }\r\n\r\n const controller = new AbortController();\r\n const timeoutId = setTimeout(() => controller.abort(), 8000);\r\n\r\n try {\r\n const response = await fetch(targetUrl.toString(), {\r\n signal: controller.signal,\r\n headers: {\r\n \"User-Agent\":\r\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\r\n Accept:\r\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\r\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\r\n },\r\n redirect: \"follow\",\r\n });\r\n\r\n clearTimeout(timeoutId);\r\n\r\n if (!response.ok) {\r\n throw new Error(\r\n `Failed to fetch URL: ${response.status} ${response.statusText}`\r\n );\r\n }\r\n\r\n const html = await response.text();\r\n return parseMetaTags(html, targetUrl.toString());\r\n } catch (error) {\r\n clearTimeout(timeoutId);\r\n throw error;\r\n }\r\n}\r\n\r\nfunction jsonResponse(data: unknown, status = 200): Response {\r\n return new Response(JSON.stringify(data), {\r\n status,\r\n headers: { \"Content-Type\": \"application/json\" },\r\n });\r\n}\r\n\r\n/**\r\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\r\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\r\n *\r\n * @example\r\n * // Next.js: src/app/api/link-preview/route.ts\r\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\r\n */\r\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\r\n const { searchParams } = new URL(request.url);\r\n const url = searchParams.get(\"url\");\r\n\r\n if (!url) {\r\n return jsonResponse({ error: \"url parameter is required\" }, 400);\r\n }\r\n\r\n let targetUrl: URL;\r\n try {\r\n targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n return jsonResponse(\r\n { error: \"Only http and https URLs are allowed\" },\r\n 400\r\n );\r\n }\r\n } catch {\r\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\r\n }\r\n\r\n try {\r\n const metadata = await fetchUrlMetadata(targetUrl.toString());\r\n return jsonResponse(metadata);\r\n } catch (error: any) {\r\n if (error.name === \"AbortError\") {\r\n return jsonResponse({ error: \"Request timeout\" }, 408);\r\n }\r\n\r\n console.error(\"Error fetching link metadata:\", error);\r\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\n * @lumir-company/editor - Link Preview API Handler\n *\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\n *\n * Next.js App Router 사용 예시:\n * ```ts\n * // src/app/api/link-preview/route.ts\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\n * ```\n */\n\nexport interface LinkMetadata {\n url: string;\n title: string;\n description?: string;\n image?: string;\n domain: string;\n}\n\nfunction extractDomain(url: string): string {\n try {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n } catch {\n return url;\n }\n}\n\nfunction decodeHtmlEntity(str: string): string {\n return str\n .replace(/&amp;/g, \"&\")\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&quot;/g, '\"')\n .replace(/&#39;/g, \"'\")\n .replace(/&nbsp;/g, \" \")\n .replace(/&#x27;/g, \"'\")\n .replace(/&#x2F;/g, \"/\");\n}\n\nfunction getMetaContent(\n html: string,\n property: string,\n name?: string\n): string | null {\n const patterns = [\n new RegExp(\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\n \"i\"\n ),\n ];\n\n if (name) {\n patterns.push(\n new RegExp(\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\n \"i\"\n )\n );\n }\n\n for (const pattern of patterns) {\n const match = html.match(pattern);\n if (match?.[1]) return match[1].trim();\n }\n\n return null;\n}\n\n/**\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\n */\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\n const domain = extractDomain(baseUrl);\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\n\n const ogTitle = getMetaContent(html, \"og:title\");\n const ogDescription = getMetaContent(html, \"og:description\");\n const ogImage = getMetaContent(html, \"og:image\");\n const ogUrl = getMetaContent(html, \"og:url\");\n\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\n\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n const descriptionMatch = html.match(\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\n );\n\n if (ogTitle) {\n metadata.title = decodeHtmlEntity(ogTitle);\n } else if (twitterTitle) {\n metadata.title = decodeHtmlEntity(twitterTitle);\n } else if (titleMatch?.[1]) {\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\n }\n\n if (ogDescription) {\n metadata.description = decodeHtmlEntity(ogDescription);\n } else if (twitterDescription) {\n metadata.description = decodeHtmlEntity(twitterDescription);\n } else if (descriptionMatch?.[1]) {\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\n }\n\n let imageUrl: string | undefined;\n if (ogImage) {\n imageUrl = ogImage;\n } else if (twitterImage) {\n imageUrl = twitterImage;\n }\n\n if (imageUrl) {\n imageUrl = decodeHtmlEntity(imageUrl);\n if (imageUrl.trim()) {\n try {\n metadata.image = new URL(imageUrl, baseUrl).toString();\n } catch {\n metadata.image = undefined;\n }\n }\n }\n\n if (ogUrl) {\n try {\n metadata.url = new URL(ogUrl, baseUrl).toString();\n } catch {\n // keep original URL\n }\n }\n\n return metadata;\n}\n\n/**\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\n */\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\n const targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n throw new Error(\"Only http and https URLs are allowed\");\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 8000);\n\n try {\n const response = await fetch(targetUrl.toString(), {\n signal: controller.signal,\n headers: {\n \"User-Agent\":\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n Accept:\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\n },\n redirect: \"follow\",\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n throw new Error(\n `Failed to fetch URL: ${response.status} ${response.statusText}`\n );\n }\n\n const html = await response.text();\n return parseMetaTags(html, targetUrl.toString());\n } catch (error) {\n clearTimeout(timeoutId);\n throw error;\n }\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\n/**\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\n *\n * @example\n * // Next.js: src/app/api/link-preview/route.ts\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\n */\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const url = searchParams.get(\"url\");\n\n if (!url) {\n return jsonResponse({ error: \"url parameter is required\" }, 400);\n }\n\n let targetUrl: URL;\n try {\n targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n return jsonResponse(\n { error: \"Only http and https URLs are allowed\" },\n 400\n );\n }\n } catch {\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\n }\n\n try {\n const metadata = await fetchUrlMetadata(targetUrl.toString());\n return jsonResponse(metadata);\n } catch (error: any) {\n if (error.name === \"AbortError\") {\n return jsonResponse({ error: \"Request timeout\" }, 408);\n }\n\n console.error(\"Error fetching link metadata:\", error);\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\n }\n}\n"],"mappings":"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","names":[]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\r\n * @lumir-company/editor - Link Preview API Handler\r\n *\r\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\r\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\r\n *\r\n * Next.js App Router 사용 예시:\r\n * ```ts\r\n * // src/app/api/link-preview/route.ts\r\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\r\n * ```\r\n */\r\n\r\nexport interface LinkMetadata {\r\n url: string;\r\n title: string;\r\n description?: string;\r\n image?: string;\r\n domain: string;\r\n}\r\n\r\nfunction extractDomain(url: string): string {\r\n try {\r\n return new URL(url).hostname.replace(/^www\\./, \"\");\r\n } catch {\r\n return url;\r\n }\r\n}\r\n\r\nfunction decodeHtmlEntity(str: string): string {\r\n return str\r\n .replace(/&amp;/g, \"&\")\r\n .replace(/&lt;/g, \"<\")\r\n .replace(/&gt;/g, \">\")\r\n .replace(/&quot;/g, '\"')\r\n .replace(/&#39;/g, \"'\")\r\n .replace(/&nbsp;/g, \" \")\r\n .replace(/&#x27;/g, \"'\")\r\n .replace(/&#x2F;/g, \"/\");\r\n}\r\n\r\nfunction getMetaContent(\r\n html: string,\r\n property: string,\r\n name?: string\r\n): string | null {\r\n const patterns = [\r\n new RegExp(\r\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\r\n \"i\"\r\n ),\r\n ];\r\n\r\n if (name) {\r\n patterns.push(\r\n new RegExp(\r\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\r\n \"i\"\r\n ),\r\n new RegExp(\r\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\r\n \"i\"\r\n )\r\n );\r\n }\r\n\r\n for (const pattern of patterns) {\r\n const match = html.match(pattern);\r\n if (match?.[1]) return match[1].trim();\r\n }\r\n\r\n return null;\r\n}\r\n\r\n/**\r\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\r\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\r\n */\r\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\r\n const domain = extractDomain(baseUrl);\r\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\r\n\r\n const ogTitle = getMetaContent(html, \"og:title\");\r\n const ogDescription = getMetaContent(html, \"og:description\");\r\n const ogImage = getMetaContent(html, \"og:image\");\r\n const ogUrl = getMetaContent(html, \"og:url\");\r\n\r\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\r\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\r\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\r\n\r\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\r\n const descriptionMatch = html.match(\r\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\r\n );\r\n\r\n if (ogTitle) {\r\n metadata.title = decodeHtmlEntity(ogTitle);\r\n } else if (twitterTitle) {\r\n metadata.title = decodeHtmlEntity(twitterTitle);\r\n } else if (titleMatch?.[1]) {\r\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\r\n }\r\n\r\n if (ogDescription) {\r\n metadata.description = decodeHtmlEntity(ogDescription);\r\n } else if (twitterDescription) {\r\n metadata.description = decodeHtmlEntity(twitterDescription);\r\n } else if (descriptionMatch?.[1]) {\r\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\r\n }\r\n\r\n let imageUrl: string | undefined;\r\n if (ogImage) {\r\n imageUrl = ogImage;\r\n } else if (twitterImage) {\r\n imageUrl = twitterImage;\r\n }\r\n\r\n if (imageUrl) {\r\n imageUrl = decodeHtmlEntity(imageUrl);\r\n if (imageUrl.trim()) {\r\n try {\r\n metadata.image = new URL(imageUrl, baseUrl).toString();\r\n } catch {\r\n metadata.image = undefined;\r\n }\r\n }\r\n }\r\n\r\n if (ogUrl) {\r\n try {\r\n metadata.url = new URL(ogUrl, baseUrl).toString();\r\n } catch {\r\n // keep original URL\r\n }\r\n }\r\n\r\n return metadata;\r\n}\r\n\r\n/**\r\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\r\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\r\n */\r\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\r\n const targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n throw new Error(\"Only http and https URLs are allowed\");\r\n }\r\n\r\n const controller = new AbortController();\r\n const timeoutId = setTimeout(() => controller.abort(), 8000);\r\n\r\n try {\r\n const response = await fetch(targetUrl.toString(), {\r\n signal: controller.signal,\r\n headers: {\r\n \"User-Agent\":\r\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\r\n Accept:\r\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\r\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\r\n },\r\n redirect: \"follow\",\r\n });\r\n\r\n clearTimeout(timeoutId);\r\n\r\n if (!response.ok) {\r\n throw new Error(\r\n `Failed to fetch URL: ${response.status} ${response.statusText}`\r\n );\r\n }\r\n\r\n const html = await response.text();\r\n return parseMetaTags(html, targetUrl.toString());\r\n } catch (error) {\r\n clearTimeout(timeoutId);\r\n throw error;\r\n }\r\n}\r\n\r\nfunction jsonResponse(data: unknown, status = 200): Response {\r\n return new Response(JSON.stringify(data), {\r\n status,\r\n headers: { \"Content-Type\": \"application/json\" },\r\n });\r\n}\r\n\r\n/**\r\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\r\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\r\n *\r\n * @example\r\n * // Next.js: src/app/api/link-preview/route.ts\r\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\r\n */\r\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\r\n const { searchParams } = new URL(request.url);\r\n const url = searchParams.get(\"url\");\r\n\r\n if (!url) {\r\n return jsonResponse({ error: \"url parameter is required\" }, 400);\r\n }\r\n\r\n let targetUrl: URL;\r\n try {\r\n targetUrl = new URL(url);\r\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\r\n return jsonResponse(\r\n { error: \"Only http and https URLs are allowed\" },\r\n 400\r\n );\r\n }\r\n } catch {\r\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\r\n }\r\n\r\n try {\r\n const metadata = await fetchUrlMetadata(targetUrl.toString());\r\n return jsonResponse(metadata);\r\n } catch (error: any) {\r\n if (error.name === \"AbortError\") {\r\n return jsonResponse({ error: \"Request timeout\" }, 408);\r\n }\r\n\r\n console.error(\"Error fetching link metadata:\", error);\r\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\r\n }\r\n}\r\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/api/link-preview.ts"],"sourcesContent":["/**\n * @lumir-company/editor - Link Preview API Handler\n *\n * 서버 사이드 전용 모듈입니다. Next.js App Router, Remix, SvelteKit 등\n * Web API 표준(Request/Response)을 지원하는 프레임워크에서 사용할 수 있습니다.\n *\n * Next.js App Router 사용 예시:\n * ```ts\n * // src/app/api/link-preview/route.ts\n * export { GET } from \"@lumir-company/editor/api/link-preview\";\n * ```\n */\n\nexport interface LinkMetadata {\n url: string;\n title: string;\n description?: string;\n image?: string;\n domain: string;\n}\n\nfunction extractDomain(url: string): string {\n try {\n return new URL(url).hostname.replace(/^www\\./, \"\");\n } catch {\n return url;\n }\n}\n\nfunction decodeHtmlEntity(str: string): string {\n return str\n .replace(/&amp;/g, \"&\")\n .replace(/&lt;/g, \"<\")\n .replace(/&gt;/g, \">\")\n .replace(/&quot;/g, '\"')\n .replace(/&#39;/g, \"'\")\n .replace(/&nbsp;/g, \" \")\n .replace(/&#x27;/g, \"'\")\n .replace(/&#x2F;/g, \"/\");\n}\n\nfunction getMetaContent(\n html: string,\n property: string,\n name?: string\n): string | null {\n const patterns = [\n new RegExp(\n `<meta\\\\s+property=[\"']${property}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+property=[\"']${property}[\"']`,\n \"i\"\n ),\n ];\n\n if (name) {\n patterns.push(\n new RegExp(\n `<meta\\\\s+name=[\"']${name}[\"']\\\\s+content=[\"']([^\"']+)[\"']`,\n \"i\"\n ),\n new RegExp(\n `<meta\\\\s+content=[\"']([^\"']+)[\"']\\\\s+name=[\"']${name}[\"']`,\n \"i\"\n )\n );\n }\n\n for (const pattern of patterns) {\n const match = html.match(pattern);\n if (match?.[1]) return match[1].trim();\n }\n\n return null;\n}\n\n/**\n * HTML 문자열에서 Open Graph / Twitter Card 메타데이터를 파싱합니다.\n * 커스텀 서버 구현 시 직접 사용할 수 있습니다.\n */\nexport function parseMetaTags(html: string, baseUrl: string): LinkMetadata {\n const domain = extractDomain(baseUrl);\n const metadata: LinkMetadata = { url: baseUrl, title: domain, domain };\n\n const ogTitle = getMetaContent(html, \"og:title\");\n const ogDescription = getMetaContent(html, \"og:description\");\n const ogImage = getMetaContent(html, \"og:image\");\n const ogUrl = getMetaContent(html, \"og:url\");\n\n const twitterTitle = getMetaContent(html, \"\", \"twitter:title\");\n const twitterDescription = getMetaContent(html, \"\", \"twitter:description\");\n const twitterImage = getMetaContent(html, \"\", \"twitter:image\");\n\n const titleMatch = html.match(/<title[^>]*>([^<]+)<\\/title>/i);\n const descriptionMatch = html.match(\n /<meta\\s+name=[\"']description[\"']\\s+content=[\"']([^\"']+)[\"']/i\n );\n\n if (ogTitle) {\n metadata.title = decodeHtmlEntity(ogTitle);\n } else if (twitterTitle) {\n metadata.title = decodeHtmlEntity(twitterTitle);\n } else if (titleMatch?.[1]) {\n metadata.title = decodeHtmlEntity(titleMatch[1].trim());\n }\n\n if (ogDescription) {\n metadata.description = decodeHtmlEntity(ogDescription);\n } else if (twitterDescription) {\n metadata.description = decodeHtmlEntity(twitterDescription);\n } else if (descriptionMatch?.[1]) {\n metadata.description = decodeHtmlEntity(descriptionMatch[1].trim());\n }\n\n let imageUrl: string | undefined;\n if (ogImage) {\n imageUrl = ogImage;\n } else if (twitterImage) {\n imageUrl = twitterImage;\n }\n\n if (imageUrl) {\n imageUrl = decodeHtmlEntity(imageUrl);\n if (imageUrl.trim()) {\n try {\n metadata.image = new URL(imageUrl, baseUrl).toString();\n } catch {\n metadata.image = undefined;\n }\n }\n }\n\n if (ogUrl) {\n try {\n metadata.url = new URL(ogUrl, baseUrl).toString();\n } catch {\n // keep original URL\n }\n }\n\n return metadata;\n}\n\n/**\n * URL에서 메타데이터를 가져옵니다 (서버 사이드 전용).\n * Express, Fastify 등 커스텀 서버에서 직접 사용할 수 있습니다.\n */\nexport async function fetchUrlMetadata(url: string): Promise<LinkMetadata> {\n const targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n throw new Error(\"Only http and https URLs are allowed\");\n }\n\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 8000);\n\n try {\n const response = await fetch(targetUrl.toString(), {\n signal: controller.signal,\n headers: {\n \"User-Agent\":\n \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\",\n Accept:\n \"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\",\n \"Accept-Language\": \"ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7\",\n },\n redirect: \"follow\",\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n throw new Error(\n `Failed to fetch URL: ${response.status} ${response.statusText}`\n );\n }\n\n const html = await response.text();\n return parseMetaTags(html, targetUrl.toString());\n } catch (error) {\n clearTimeout(timeoutId);\n throw error;\n }\n}\n\nfunction jsonResponse(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\n/**\n * 링크 프리뷰 메타데이터 조회 핸들러 (Web API 표준 Request/Response).\n * Next.js App Router, Remix, SvelteKit 등에서 re-export하여 사용합니다.\n *\n * @example\n * // Next.js: src/app/api/link-preview/route.ts\n * export { linkPreviewHandler as GET } from \"@lumir-company/editor/api/link-preview\";\n */\nexport async function linkPreviewHandler(request: Request): Promise<Response> {\n const { searchParams } = new URL(request.url);\n const url = searchParams.get(\"url\");\n\n if (!url) {\n return jsonResponse({ error: \"url parameter is required\" }, 400);\n }\n\n let targetUrl: URL;\n try {\n targetUrl = new URL(url);\n if (![\"http:\", \"https:\"].includes(targetUrl.protocol)) {\n return jsonResponse(\n { error: \"Only http and https URLs are allowed\" },\n 400\n );\n }\n } catch {\n return jsonResponse({ error: \"Invalid URL format\" }, 400);\n }\n\n try {\n const metadata = await fetchUrlMetadata(targetUrl.toString());\n return jsonResponse(metadata);\n } catch (error: any) {\n if (error.name === \"AbortError\") {\n return jsonResponse({ error: \"Request timeout\" }, 408);\n }\n\n console.error(\"Error fetching link metadata:\", error);\n return jsonResponse({ error: \"Failed to fetch link metadata\" }, 500);\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
1
+ import * as react from 'react';
2
+ import react__default from 'react';
2
3
  import * as _blocknote_core from '@blocknote/core';
3
4
  import { PartialBlock, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
4
5
  export { BlockNoteEditor, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, PartialBlock } from '@blocknote/core';
5
- import React from 'react';
6
6
 
7
7
  /**
8
8
  * LumirEditor 커스텀 에러 클래스
@@ -256,7 +256,7 @@ declare class EditorConfig {
256
256
  */
257
257
  static getDisabledExtensions(userExtensions?: string[], allowVideo?: boolean, allowAudio?: boolean, allowFile?: boolean): string[];
258
258
  }
259
- declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps): react_jsx_runtime.JSX.Element;
259
+ declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps): react.JSX.Element;
260
260
 
261
261
  declare function cn(...inputs: (string | undefined | null | false)[]): string;
262
262
 
@@ -1062,7 +1062,7 @@ interface FloatingMenuProps {
1062
1062
  /**
1063
1063
  * FloatingMenu - 에디터 상단 고정 툴바
1064
1064
  */
1065
- declare const FloatingMenu: React.FC<FloatingMenuProps>;
1065
+ declare const FloatingMenu: react__default.FC<FloatingMenuProps>;
1066
1066
 
1067
1067
  /**
1068
1068
  * 인라인 글자 크기 커스텀 스타일.
@@ -1097,7 +1097,7 @@ declare function clampFontSizePx(px: number): number;
1097
1097
  /** 숫자 → "Npx" (clamp 포함). */
1098
1098
  declare function toFontSizeValue(px: number): string;
1099
1099
 
1100
- declare function FontSizeButton(): react_jsx_runtime.JSX.Element | null;
1100
+ declare function FontSizeButton(): react.JSX.Element | null;
1101
1101
 
1102
1102
  /**
1103
1103
  * 색상 팔레트 상수
package/dist/index.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import * as react_jsx_runtime from 'react/jsx-runtime';
1
+ import * as react from 'react';
2
+ import react__default from 'react';
2
3
  import * as _blocknote_core from '@blocknote/core';
3
4
  import { PartialBlock, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, BlockNoteEditor, BlockNoteSchema } from '@blocknote/core';
4
5
  export { BlockNoteEditor, DefaultBlockSchema, DefaultInlineContentSchema, DefaultStyleSchema, PartialBlock } from '@blocknote/core';
5
- import React from 'react';
6
6
 
7
7
  /**
8
8
  * LumirEditor 커스텀 에러 클래스
@@ -256,7 +256,7 @@ declare class EditorConfig {
256
256
  */
257
257
  static getDisabledExtensions(userExtensions?: string[], allowVideo?: boolean, allowAudio?: boolean, allowFile?: boolean): string[];
258
258
  }
259
- declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps): react_jsx_runtime.JSX.Element;
259
+ declare function LumirEditor({ initialContent, initialEmptyBlocks, uploadFile, s3Upload, tables, heading, defaultStyles, disableExtensions, tabBehavior, trailingBlock, allowVideoUpload, allowAudioUpload, allowFileUpload, maxImageFileSize, maxVideoFileSize, linkPreview, editable, theme, formattingToolbar, linkToolbar, sideMenu, emojiPicker, filePanel, tableHandles, onSelectionChange, className, placeholder, sideMenuAddButton, columnDivider, floatingMenu, floatingMenuPosition, onContentChange, onError, onImageDelete, }: LumirEditorProps): react.JSX.Element;
260
260
 
261
261
  declare function cn(...inputs: (string | undefined | null | false)[]): string;
262
262
 
@@ -1062,7 +1062,7 @@ interface FloatingMenuProps {
1062
1062
  /**
1063
1063
  * FloatingMenu - 에디터 상단 고정 툴바
1064
1064
  */
1065
- declare const FloatingMenu: React.FC<FloatingMenuProps>;
1065
+ declare const FloatingMenu: react__default.FC<FloatingMenuProps>;
1066
1066
 
1067
1067
  /**
1068
1068
  * 인라인 글자 크기 커스텀 스타일.
@@ -1097,7 +1097,7 @@ declare function clampFontSizePx(px: number): number;
1097
1097
  /** 숫자 → "Npx" (clamp 포함). */
1098
1098
  declare function toFontSizeValue(px: number): string;
1099
1099
 
1100
- declare function FontSizeButton(): react_jsx_runtime.JSX.Element | null;
1100
+ declare function FontSizeButton(): react.JSX.Element | null;
1101
1101
 
1102
1102
  /**
1103
1103
  * 색상 팔레트 상수
package/dist/index.js CHANGED
@@ -55,7 +55,7 @@ module.exports = __toCommonJS(index_exports);
55
55
  var import_react36 = require("react");
56
56
  var import_react37 = require("@blocknote/react");
57
57
  var import_mantine = require("@blocknote/mantine");
58
- var import_core11 = require("@blocknote/core");
58
+ var import_core12 = require("@blocknote/core");
59
59
  var import_locales = require("@blocknote/core/locales");
60
60
 
61
61
  // src/utils/cn.ts
@@ -1610,6 +1610,40 @@ function clampFontSizePx(px) {
1610
1610
  function toFontSizeValue(px) {
1611
1611
  return `${clampFontSizePx(px)}px`;
1612
1612
  }
1613
+ function readSelectionFontSize(editor) {
1614
+ const ed = editor;
1615
+ const fallback = () => {
1616
+ try {
1617
+ return ed?.getActiveStyles?.().fontSize || "";
1618
+ } catch {
1619
+ return "";
1620
+ }
1621
+ };
1622
+ try {
1623
+ const tt = ed._tiptapEditor;
1624
+ const state = tt?.state;
1625
+ if (!state) return fallback();
1626
+ const sel = state.selection;
1627
+ if (sel.empty) {
1628
+ const marks = state.storedMarks || sel.$to.marks();
1629
+ const m = marks?.find?.((mk) => mk.type?.name === "fontSize");
1630
+ return m?.attrs?.stringValue || "";
1631
+ }
1632
+ let value = null;
1633
+ let mixed = false;
1634
+ state.doc.nodesBetween(sel.from, sel.to, (node) => {
1635
+ if (mixed || !node.isText) return !mixed;
1636
+ const m = node.marks?.find?.((mk) => mk.type?.name === "fontSize");
1637
+ const v = m?.attrs?.stringValue || "";
1638
+ if (value === null) value = v;
1639
+ else if (value !== v) mixed = true;
1640
+ return !mixed;
1641
+ });
1642
+ return mixed ? "" : value || "";
1643
+ } catch {
1644
+ return fallback();
1645
+ }
1646
+ }
1613
1647
 
1614
1648
  // src/blocks/HtmlPreview.tsx
1615
1649
  var import_react6 = require("react");
@@ -2609,14 +2643,16 @@ var toLabel = (size) => size.replace(/px$/, "");
2609
2643
  var FontSizeButton = ({ editor }) => {
2610
2644
  const [isOpen, setIsOpen] = (0, import_react13.useState)(false);
2611
2645
  const dropdownRef = (0, import_react13.useRef)(null);
2612
- const getCurrentSize = () => {
2613
- try {
2614
- return editor?.getActiveStyles?.()?.fontSize || "";
2615
- } catch {
2616
- return "";
2646
+ const live = readSelectionFontSize(editor);
2647
+ const [optimistic, setOptimistic] = (0, import_react13.useState)(null);
2648
+ const lastLiveRef = (0, import_react13.useRef)(live);
2649
+ (0, import_react13.useEffect)(() => {
2650
+ if (live !== lastLiveRef.current) {
2651
+ lastLiveRef.current = live;
2652
+ setOptimistic(null);
2617
2653
  }
2618
- };
2619
- const currentSize = getCurrentSize();
2654
+ }, [live]);
2655
+ const currentSize = optimistic ?? live;
2620
2656
  const currentPx = parseFontSizePx(currentSize);
2621
2657
  const [inputValue, setInputValue] = (0, import_react13.useState)(String(currentPx));
2622
2658
  (0, import_react13.useEffect)(() => {
@@ -2637,8 +2673,10 @@ var FontSizeButton = ({ editor }) => {
2637
2673
  if (!editor) return;
2638
2674
  if (size === "") {
2639
2675
  editor.removeStyles?.({ fontSize: "" });
2676
+ setOptimistic(null);
2640
2677
  } else {
2641
2678
  editor.addStyles?.({ fontSize: size });
2679
+ setOptimistic(size);
2642
2680
  }
2643
2681
  setIsOpen(false);
2644
2682
  setTimeout(() => editor.focus?.());
@@ -2651,9 +2689,9 @@ var FontSizeButton = ({ editor }) => {
2651
2689
  const stepBy = (0, import_react13.useCallback)(
2652
2690
  (delta) => {
2653
2691
  try {
2654
- editor?.addStyles?.({
2655
- fontSize: toFontSizeValue(currentPx + delta)
2656
- });
2692
+ const value = toFontSizeValue(currentPx + delta);
2693
+ editor?.addStyles?.({ fontSize: value });
2694
+ setOptimistic(value);
2657
2695
  } catch (err) {
2658
2696
  console.error("Font size step failed:", err);
2659
2697
  }
@@ -2664,7 +2702,9 @@ var FontSizeButton = ({ editor }) => {
2664
2702
  const n = parseInt(inputValue, 10);
2665
2703
  if (Number.isFinite(n)) {
2666
2704
  try {
2667
- editor?.addStyles?.({ fontSize: toFontSizeValue(n) });
2705
+ const value = toFontSizeValue(n);
2706
+ editor?.addStyles?.({ fontSize: value });
2707
+ setOptimistic(value);
2668
2708
  } catch (err) {
2669
2709
  console.error("Font size apply failed:", err);
2670
2710
  }
@@ -4265,8 +4305,67 @@ var TableSelectAllExtension = import_core8.Extension.create({
4265
4305
  }
4266
4306
  });
4267
4307
 
4268
- // src/blocks/columns/insertColumns.ts
4308
+ // src/extensions/InactiveSelectionExtension.ts
4309
+ var import_core9 = require("@tiptap/core");
4269
4310
  var import_prosemirror_state7 = require("prosemirror-state");
4311
+ var import_prosemirror_view5 = require("prosemirror-view");
4312
+ var inactiveSelectionKey = new import_prosemirror_state7.PluginKey("lumirInactiveSelection");
4313
+ var InactiveSelectionExtension = import_core9.Extension.create({
4314
+ name: "lumirInactiveSelection",
4315
+ addProseMirrorPlugins() {
4316
+ return [
4317
+ new import_prosemirror_state7.Plugin({
4318
+ key: inactiveSelectionKey,
4319
+ // 플러그인 state = 에디터 포커스 여부
4320
+ state: {
4321
+ init: () => false,
4322
+ apply: (tr, value) => {
4323
+ const meta = tr.getMeta(inactiveSelectionKey);
4324
+ return typeof meta === "boolean" ? meta : value;
4325
+ }
4326
+ },
4327
+ props: {
4328
+ decorations(state) {
4329
+ const hasFocus = inactiveSelectionKey.getState(state);
4330
+ if (hasFocus) {
4331
+ return null;
4332
+ }
4333
+ const { selection } = state;
4334
+ if (selection.empty || !(selection instanceof import_prosemirror_state7.TextSelection)) {
4335
+ return null;
4336
+ }
4337
+ return import_prosemirror_view5.DecorationSet.create(state.doc, [
4338
+ import_prosemirror_view5.Decoration.inline(selection.from, selection.to, {
4339
+ class: "bn-inactive-selection"
4340
+ })
4341
+ ]);
4342
+ }
4343
+ },
4344
+ view(view) {
4345
+ const sync = () => {
4346
+ const has = view.hasFocus();
4347
+ if (inactiveSelectionKey.getState(view.state) !== has) {
4348
+ view.dispatch(view.state.tr.setMeta(inactiveSelectionKey, has));
4349
+ }
4350
+ };
4351
+ view.dom.addEventListener("focus", sync);
4352
+ view.dom.addEventListener("blur", sync);
4353
+ const raf = requestAnimationFrame(sync);
4354
+ return {
4355
+ destroy() {
4356
+ cancelAnimationFrame(raf);
4357
+ view.dom.removeEventListener("focus", sync);
4358
+ view.dom.removeEventListener("blur", sync);
4359
+ }
4360
+ };
4361
+ }
4362
+ })
4363
+ ];
4364
+ }
4365
+ });
4366
+
4367
+ // src/blocks/columns/insertColumns.ts
4368
+ var import_prosemirror_state8 = require("prosemirror-state");
4270
4369
  function insertTwoColumns(editor, showDivider = false) {
4271
4370
  const tiptap = editor?._tiptapEditor;
4272
4371
  if (!tiptap) {
@@ -4298,7 +4397,7 @@ function insertTwoColumns(editor, showDivider = false) {
4298
4397
  try {
4299
4398
  let tr = state.tr.insert(insertPos, list);
4300
4399
  try {
4301
- tr = tr.setSelection(import_prosemirror_state7.TextSelection.create(tr.doc, insertPos + 4));
4400
+ tr = tr.setSelection(import_prosemirror_state8.TextSelection.create(tr.doc, insertPos + 4));
4302
4401
  } catch {
4303
4402
  }
4304
4403
  tiptap.view.dispatch(tr.scrollIntoView());
@@ -4312,7 +4411,7 @@ function insertTwoColumns(editor, showDivider = false) {
4312
4411
  var import_react29 = require("@blocknote/react");
4313
4412
 
4314
4413
  // src/components/TextAlignButtonWithVA.tsx
4315
- var import_core9 = require("@blocknote/core");
4414
+ var import_core10 = require("@blocknote/core");
4316
4415
  var import_react19 = require("react");
4317
4416
  var import_react20 = require("@blocknote/react");
4318
4417
  var import_jsx_runtime18 = require("react/jsx-runtime");
@@ -4334,7 +4433,7 @@ var TextAlignButtonWithVA = (props) => {
4334
4433
  const selectedBlocks = (0, import_react20.useSelectedBlocks)(editor);
4335
4434
  const textAlignment = (0, import_react19.useMemo)(() => {
4336
4435
  const block = selectedBlocks[0];
4337
- if ((0, import_core9.checkBlockHasDefaultProp)("textAlignment", block, editor)) {
4436
+ if ((0, import_core10.checkBlockHasDefaultProp)("textAlignment", block, editor)) {
4338
4437
  return block.props.textAlignment;
4339
4438
  }
4340
4439
  if (block.type === "table") {
@@ -4343,7 +4442,7 @@ var TextAlignButtonWithVA = (props) => {
4343
4442
  return;
4344
4443
  }
4345
4444
  const allCellsInTable = cellSelection.cells.map(
4346
- ({ row, col }) => (0, import_core9.mapTableCell)(
4445
+ ({ row, col }) => (0, import_core10.mapTableCell)(
4347
4446
  block.content.rows[row].cells[col]
4348
4447
  ).props.textAlignment
4349
4448
  );
@@ -4375,7 +4474,7 @@ var TextAlignButtonWithVA = (props) => {
4375
4474
  }
4376
4475
  }
4377
4476
  tiptap.view?.dispatch(tr);
4378
- } else if ((0, import_core9.checkBlockTypeHasDefaultProp)("textAlignment", block.type, editor)) {
4477
+ } else if ((0, import_core10.checkBlockTypeHasDefaultProp)("textAlignment", block.type, editor)) {
4379
4478
  editor.updateBlock(block, {
4380
4479
  props: { textAlignment: newAlignment }
4381
4480
  });
@@ -4580,11 +4679,11 @@ function FontSizeButton2() {
4580
4679
  const fontSizeInSchema = styleSchema.fontSize?.type === "fontSize" && styleSchema.fontSize?.propSchema === "string";
4581
4680
  const selectedBlocks = (0, import_react25.useSelectedBlocks)(editor);
4582
4681
  const [currentSize, setCurrentSize] = (0, import_react26.useState)(
4583
- fontSizeInSchema ? ed.getActiveStyles().fontSize || "" : ""
4682
+ fontSizeInSchema ? readSelectionFontSize(editor) : ""
4584
4683
  );
4585
4684
  (0, import_react25.useEditorContentOrSelectionChange)(() => {
4586
4685
  if (fontSizeInSchema) {
4587
- setCurrentSize(ed.getActiveStyles().fontSize || "");
4686
+ setCurrentSize(readSelectionFontSize(editor));
4588
4687
  }
4589
4688
  }, editor);
4590
4689
  const currentPx = parseFontSizePx(currentSize);
@@ -4602,7 +4701,9 @@ function FontSizeButton2() {
4602
4701
  );
4603
4702
  const stepBy = (0, import_react26.useCallback)(
4604
4703
  (delta) => {
4605
- ed.addStyles({ fontSize: toFontSizeValue(currentPx + delta) });
4704
+ const value = toFontSizeValue(currentPx + delta);
4705
+ ed.addStyles({ fontSize: value });
4706
+ setCurrentSize(value);
4606
4707
  },
4607
4708
  // eslint-disable-next-line react-hooks/exhaustive-deps
4608
4709
  [currentPx]
@@ -4610,7 +4711,9 @@ function FontSizeButton2() {
4610
4711
  const applyInput = (0, import_react26.useCallback)(() => {
4611
4712
  const n = parseInt(inputValue, 10);
4612
4713
  if (Number.isFinite(n)) {
4613
- ed.addStyles({ fontSize: toFontSizeValue(n) });
4714
+ const value = toFontSizeValue(n);
4715
+ ed.addStyles({ fontSize: value });
4716
+ setCurrentSize(value);
4614
4717
  } else {
4615
4718
  setInputValue(String(currentPx));
4616
4719
  }
@@ -4724,7 +4827,7 @@ function FontSizeButton2() {
4724
4827
  }
4725
4828
 
4726
4829
  // src/components/color/LumirColorControls.tsx
4727
- var import_core10 = require("@blocknote/core");
4830
+ var import_core11 = require("@blocknote/core");
4728
4831
  var import_react27 = require("@blocknote/react");
4729
4832
  var import_react28 = require("react");
4730
4833
  var import_jsx_runtime22 = require("react/jsx-runtime");
@@ -4972,7 +5075,7 @@ function LumirCellColorPickerButton(props) {
4972
5075
  const updateColor = (color, type) => {
4973
5076
  const newTable = props.block.content.rows.map((row) => ({
4974
5077
  ...row,
4975
- cells: row.cells.map((cell) => (0, import_core10.mapTableCell)(cell))
5078
+ cells: row.cells.map((cell) => (0, import_core11.mapTableCell)(cell))
4976
5079
  }));
4977
5080
  if (type === "text") {
4978
5081
  newTable[props.rowIndex].cells[props.colIndex].props.textColor = color;
@@ -5003,11 +5106,11 @@ function LumirCellColorPickerButton(props) {
5003
5106
  textTitle: "\uC140 \uAE00\uC790\uC0C9",
5004
5107
  backgroundTitle: "\uC140 \uBC30\uACBD",
5005
5108
  text: editor.settings.tables.cellTextColor ? {
5006
- color: (0, import_core10.isTableCell)(currentCell) ? currentCell.props.textColor : "default",
5109
+ color: (0, import_core11.isTableCell)(currentCell) ? currentCell.props.textColor : "default",
5007
5110
  setColor: (color) => updateColor(color, "text")
5008
5111
  } : void 0,
5009
5112
  background: editor.settings.tables.cellBackgroundColor ? {
5010
- color: (0, import_core10.isTableCell)(currentCell) ? currentCell.props.backgroundColor : "default",
5113
+ color: (0, import_core11.isTableCell)(currentCell) ? currentCell.props.backgroundColor : "default",
5011
5114
  setColor: (color) => updateColor(color, "background")
5012
5115
  } : void 0
5013
5116
  }
@@ -6811,7 +6914,9 @@ function LumirEditor({
6811
6914
  // 표 블록 정렬(좌/가운데/우) attr.
6812
6915
  TableAlignmentExtension,
6813
6916
  // 셀 포커스 시 Ctrl/Cmd+A → 표 전체 선택.
6814
- TableSelectAllExtension
6917
+ TableSelectAllExtension,
6918
+ // blur 상태(툴바 드롭다운 조작 등)에서도 텍스트 선택 하이라이트 유지.
6919
+ InactiveSelectionExtension
6815
6920
  ]
6816
6921
  },
6817
6922
  placeholders: placeholder ? { default: placeholder, emptyDocument: placeholder } : void 0,
@@ -7470,7 +7575,7 @@ function LumirEditor({
7470
7575
  allItems.push({
7471
7576
  title: "Link Preview",
7472
7577
  onItemClick: () => {
7473
- (0, import_core11.insertOrUpdateBlock)(editor, {
7578
+ (0, import_core12.insertOrUpdateBlock)(editor, {
7474
7579
  type: "linkPreview",
7475
7580
  props: { url: "" }
7476
7581
  });