@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.
- package/README.md +1728 -1698
- package/dist/api/link-preview.js.map +1 -1
- package/dist/api/link-preview.mjs.map +1 -1
- package/dist/index.d.mts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +133 -28
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +123 -18
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +1463 -1457
- package/package.json +103 -100
|
@@ -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(/&/g, \"&\")\r\n .replace(/</g, \"<\")\r\n .replace(/>/g, \">\")\r\n .replace(/"/g, '\"')\r\n .replace(/'/g, \"'\")\r\n .replace(/ /g, \" \")\r\n .replace(/'/g, \"'\")\r\n .replace(///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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAqBA,SAAS,cAAc,KAAqB;AAC1C,MAAI;AACF,WAAO,IAAI,IAAI,GAAG,EAAE,SAAS,QAAQ,UAAU,EAAE;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,iBAAiB,KAAqB;AAC7C,SAAO,IACJ,QAAQ,UAAU,GAAG,EACrB,QAAQ,SAAS,GAAG,EACpB,QAAQ,SAAS,GAAG,EACpB,QAAQ,WAAW,GAAG,EACtB,QAAQ,UAAU,GAAG,EACrB,QAAQ,WAAW,GAAG,EACtB,QAAQ,WAAW,GAAG,EACtB,QAAQ,WAAW,GAAG;AAC3B;AAEA,SAAS,eACP,MACA,UACA,MACe;AACf,QAAM,WAAW;AAAA,IACf,IAAI;AAAA,MACF,yBAAyB,QAAQ;AAAA,MACjC;AAAA,IACF;AAAA,IACA,IAAI;AAAA,MACF,qDAAqD,QAAQ;AAAA,MAC7D;AAAA,IACF;AAAA,EACF;AAEA,MAAI,MAAM;AACR,aAAS;AAAA,MACP,IAAI;AAAA,QACF,qBAAqB,IAAI;AAAA,QACzB;AAAA,MACF;AAAA,MACA,IAAI;AAAA,QACF,iDAAiD,IAAI;AAAA,QACrD;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,aAAW,WAAW,UAAU;AAC9B,UAAM,QAAQ,KAAK,MAAM,OAAO;AAChC,QAAI,QAAQ,CAAC,EAAG,QAAO,MAAM,CAAC,EAAE,KAAK;AAAA,EACvC;AAEA,SAAO;AACT;AAMO,SAAS,cAAc,MAAc,SAA+B;AACzE,QAAM,SAAS,cAAc,OAAO;AACpC,QAAM,WAAyB,EAAE,KAAK,SAAS,OAAO,QAAQ,OAAO;AAErE,QAAM,UAAU,eAAe,MAAM,UAAU;AAC/C,QAAM,gBAAgB,eAAe,MAAM,gBAAgB;AAC3D,QAAM,UAAU,eAAe,MAAM,UAAU;AAC/C,QAAM,QAAQ,eAAe,MAAM,QAAQ;AAE3C,QAAM,eAAe,eAAe,MAAM,IAAI,eAAe;AAC7D,QAAM,qBAAqB,eAAe,MAAM,IAAI,qBAAqB;AACzE,QAAM,eAAe,eAAe,MAAM,IAAI,eAAe;AAE7D,QAAM,aAAa,KAAK,MAAM,+BAA+B;AAC7D,QAAM,mBAAmB,KAAK;AAAA,IAC5B;AAAA,EACF;AAEA,MAAI,SAAS;AACX,aAAS,QAAQ,iBAAiB,OAAO;AAAA,EAC3C,WAAW,cAAc;AACvB,aAAS,QAAQ,iBAAiB,YAAY;AAAA,EAChD,WAAW,aAAa,CAAC,GAAG;AAC1B,aAAS,QAAQ,iBAAiB,WAAW,CAAC,EAAE,KAAK,CAAC;AAAA,EACxD;AAEA,MAAI,eAAe;AACjB,aAAS,cAAc,iBAAiB,aAAa;AAAA,EACvD,WAAW,oBAAoB;AAC7B,aAAS,cAAc,iBAAiB,kBAAkB;AAAA,EAC5D,WAAW,mBAAmB,CAAC,GAAG;AAChC,aAAS,cAAc,iBAAiB,iBAAiB,CAAC,EAAE,KAAK,CAAC;AAAA,EACpE;AAEA,MAAI;AACJ,MAAI,SAAS;AACX,eAAW;AAAA,EACb,WAAW,cAAc;AACvB,eAAW;AAAA,EACb;AAEA,MAAI,UAAU;AACZ,eAAW,iBAAiB,QAAQ;AACpC,QAAI,SAAS,KAAK,GAAG;AACnB,UAAI;AACF,iBAAS,QAAQ,IAAI,IAAI,UAAU,OAAO,EAAE,SAAS;AAAA,MACvD,QAAQ;AACN,iBAAS,QAAQ;AAAA,MACnB;AAAA,IACF;AAAA,EACF;AAEA,MAAI,OAAO;AACT,QAAI;AACF,eAAS,MAAM,IAAI,IAAI,OAAO,OAAO,EAAE,SAAS;AAAA,IAClD,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,SAAO;AACT;AAMA,eAAsB,iBAAiB,KAAoC;AACzE,QAAM,YAAY,IAAI,IAAI,GAAG;AAC7B,MAAI,CAAC,CAAC,SAAS,QAAQ,EAAE,SAAS,UAAU,QAAQ,GAAG;AACrD,UAAM,IAAI,MAAM,sCAAsC;AAAA,EACxD;AAEA,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,GAAI;AAE3D,MAAI;AACF,UAAM,WAAW,MAAM,MAAM,UAAU,SAAS,GAAG;AAAA,MACjD,QAAQ,WAAW;AAAA,MACnB,SAAS;AAAA,QACP,cACE;AAAA,QACF,QACE;AAAA,QACF,mBAAmB;AAAA,MACrB;AAAA,MACA,UAAU;AAAA,IACZ,CAAC;AAED,iBAAa,SAAS;AAEtB,QAAI,CAAC,SAAS,IAAI;AAChB,YAAM,IAAI;AAAA,QACR,wBAAwB,SAAS,MAAM,IAAI,SAAS,UAAU;AAAA,MAChE;AAAA,IACF;AAEA,UAAM,OAAO,MAAM,SAAS,KAAK;AACjC,WAAO,cAAc,MAAM,UAAU,SAAS,CAAC;AAAA,EACjD,SAAS,OAAO;AACd,iBAAa,SAAS;AACtB,UAAM;AAAA,EACR;AACF;AAEA,SAAS,aAAa,MAAe,SAAS,KAAe;AAC3D,SAAO,IAAI,SAAS,KAAK,UAAU,IAAI,GAAG;AAAA,IACxC;AAAA,IACA,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,EAChD,CAAC;AACH;AAUA,eAAsB,mBAAmB,SAAqC;AAC5E,QAAM,EAAE,aAAa,IAAI,IAAI,IAAI,QAAQ,GAAG;AAC5C,QAAM,MAAM,aAAa,IAAI,KAAK;AAElC,MAAI,CAAC,KAAK;AACR,WAAO,aAAa,EAAE,OAAO,4BAA4B,GAAG,GAAG;AAAA,EACjE;AAEA,MAAI;AACJ,MAAI;AACF,gBAAY,IAAI,IAAI,GAAG;AACvB,QAAI,CAAC,CAAC,SAAS,QAAQ,EAAE,SAAS,UAAU,QAAQ,GAAG;AACrD,aAAO;AAAA,QACL,EAAE,OAAO,uCAAuC;AAAA,QAChD;AAAA,MACF;AAAA,IACF;AAAA,EACF,QAAQ;AACN,WAAO,aAAa,EAAE,OAAO,qBAAqB,GAAG,GAAG;AAAA,EAC1D;AAEA,MAAI;AACF,UAAM,WAAW,MAAM,iBAAiB,UAAU,SAAS,CAAC;AAC5D,WAAO,aAAa,QAAQ;AAAA,EAC9B,SAAS,OAAY;AACnB,QAAI,MAAM,SAAS,cAAc;AAC/B,aAAO,aAAa,EAAE,OAAO,kBAAkB,GAAG,GAAG;AAAA,IACvD;AAEA,YAAQ,MAAM,iCAAiC,KAAK;AACpD,WAAO,aAAa,EAAE,OAAO,gCAAgC,GAAG,GAAG;AAAA,EACrE;AACF;","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(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/ /g, \" \")\n .replace(/'/g, \"'\")\n .replace(///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(/&/g, \"&\")\r\n .replace(/</g, \"<\")\r\n .replace(/>/g, \">\")\r\n .replace(/"/g, '\"')\r\n .replace(/'/g, \"'\")\r\n .replace(/ /g, \" \")\r\n .replace(/'/g, \"'\")\r\n .replace(///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(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/ /g, \" \")\n .replace(/'/g, \"'\")\n .replace(///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
|
|
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):
|
|
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:
|
|
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():
|
|
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
|
|
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):
|
|
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:
|
|
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():
|
|
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
|
|
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
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
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 =
|
|
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
|
-
|
|
2655
|
-
|
|
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
|
-
|
|
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/
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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 ?
|
|
4682
|
+
fontSizeInSchema ? readSelectionFontSize(editor) : ""
|
|
4584
4683
|
);
|
|
4585
4684
|
(0, import_react25.useEditorContentOrSelectionChange)(() => {
|
|
4586
4685
|
if (fontSizeInSchema) {
|
|
4587
|
-
setCurrentSize(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
7578
|
+
(0, import_core12.insertOrUpdateBlock)(editor, {
|
|
7474
7579
|
type: "linkPreview",
|
|
7475
7580
|
props: { url: "" }
|
|
7476
7581
|
});
|