import { createEditor, Editor, Element as SlateElement, Transforms } from 'slate'; import type { BaseEditor, Descendant } from 'slate'; import type { ReactEditor } from 'slate-react'; import type { HistoryEditor } from 'slate-history'; import { isConditionVariable, isKnownVariable } from './variables'; // ── 스키마 ──────────────────────────────────────────────── // negowiz 는 곳곳에서 `as any` 로 노드 타입을 우회했지만, 여기선 Slate 의 CustomTypes // 모듈 보강으로 editor/element/text 를 정적 타입화한다(툴바·렌더·커맨드 전부 타입 안전). export type TextMark = 'bold' | 'underline'; export type CustomText = { text: string; bold?: boolean; underline?: boolean; color?: string; // 색상 코드(#RRGGBB) }; export type VariableElement = { type: 'variable'; name: string; // CardVariable.name children: CustomText[]; // void 라 비어있음([{text:''}]) // 조건 전략(customer_condition) 전용: 별도 에디터에서 작성한 조건 내용(Slate 서브트리). // 저장 직전 attachCondition 으로 채워지고, 직렬화 시 이 위치에 조건 마커가 주입된다. condition?: Descendant[]; }; export type ParagraphElement = { type: 'paragraph'; children: (CustomText | VariableElement)[]; }; export type CustomElement = ParagraphElement | VariableElement; declare module 'slate' { interface CustomTypes { Editor: BaseEditor & ReactEditor & HistoryEditor; Element: CustomElement; Text: CustomText; } } // 항상 새 배열을 만든다(Slate 가 initialValue 를 직접 mutate 하므로 공유 금지). const cloneEmpty = (): Descendant[] => [{ type: 'paragraph', children: [{ text: '' }] }]; // ── 플러그인 ────────────────────────────────────────────── // 변수 노드를 인라인 + void(내용 수정 불가) + markableVoid(마크는 허용)로 동작시킨다. export function withVariables(editor: T): T { const { isInline, isVoid, markableVoid } = editor; editor.isInline = (el) => el.type === 'variable' || isInline(el); editor.isVoid = (el) => el.type === 'variable' || isVoid(el); editor.markableVoid = (el) => el.type === 'variable' || markableVoid(el); return editor; } export const createCardEditor = () => createEditor(); // ── 마크/색상/변수 커맨드 ────────────────────────────────── export function isMarkActive(editor: Editor, mark: TextMark): boolean { const marks = Editor.marks(editor); return marks ? marks[mark] === true : false; } export function toggleMark(editor: Editor, mark: TextMark): void { if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark); else Editor.addMark(editor, mark, true); } export function activeColor(editor: Editor): string | undefined { const marks = Editor.marks(editor) as CustomText | null; return marks?.color; } export function setColor(editor: Editor, color: string): void { Editor.addMark(editor, 'color', color); } export function clearColor(editor: Editor): void { Editor.removeMark(editor, 'color'); } export function insertVariable(editor: Editor, name: string): void { const node: VariableElement = { type: 'variable', name, children: [{ text: '' }] }; // 조건 전략은 본문에 위치 칩만 두고, 내용은 별도 에디터가 담당한다(condition 은 저장 시 병합). Transforms.insertNodes(editor, node); Transforms.move(editor); // 삽입된 void 뒤로 커서 이동 } const emptyParagraph = (): Descendant[] => [{ type: 'paragraph', children: [{ text: '' }] }]; // ── 조건 전략(customer_condition) 헬퍼 — 본문 노드 ↔ 별도 조건 에디터 분리/병합 ── export function hasConditionVariable(nodes: Descendant[]): boolean { return nodes.some( (n) => SlateElement.isElement(n) && n.type === 'variable' && isConditionVariable(n.name), ) || nodes.some( (n) => SlateElement.isElement(n) && n.type === 'paragraph' && n.children.some((c) => 'type' in c && c.type === 'variable' && isConditionVariable(c.name)), ); } // 저장된 노드에서 조건 서브트리를 꺼낸다(재편집용). 없으면 빈 문단. export function extractCondition(nodes: Descendant[]): Descendant[] { const found = findConditionNode(nodes); const c = found?.condition; return Array.isArray(c) && c.length > 0 ? (JSON.parse(JSON.stringify(c)) as Descendant[]) : emptyParagraph(); } // 본문 노드의 조건 변수에 조건 서브트리를 얹은 새 배열을 만든다(저장 직전). export function attachCondition(nodes: Descendant[], condition: Descendant[]): Descendant[] { const clone = JSON.parse(JSON.stringify(nodes)) as Descendant[]; for (const n of clone) { if (SlateElement.isElement(n) && n.type === 'variable' && isConditionVariable(n.name)) { (n as VariableElement).condition = condition; } else if (SlateElement.isElement(n) && n.type === 'paragraph') { for (const c of n.children) { if ('type' in c && c.type === 'variable' && isConditionVariable(c.name)) { (c as VariableElement).condition = condition; } } } } return clone; } function findConditionNode(nodes: Descendant[]): VariableElement | undefined { for (const n of nodes) { if (SlateElement.isElement(n)) { if (n.type === 'variable' && isConditionVariable(n.name)) return n; if (n.type === 'paragraph') { for (const c of n.children) { if ('type' in c && c.type === 'variable' && isConditionVariable(c.name)) return c; } } } } return undefined; } // ── 직렬화: Slate 값 → 평문(script) ───────────────────────── // 변수는 {name} 토큰으로, 마크는 버리고 텍스트만. 백엔드 script(미리보기) 컬럼에 저장. export function serializeToText(nodes: Descendant[]): string { return nodes.map(serializeNode).join('\n'); } function serializeNode(node: Descendant): string { if (SlateElement.isElement(node)) { if (node.type === 'variable') { // 조건 전략: 저장 위치에 조건 내용(평문)을 주입. 미작성 시 토큰 유지. if (isConditionVariable(node.name)) { return node.condition && node.condition.length ? serializeToText(node.condition) : `{${node.name}}`; } return `{${node.name}}`; } return node.children.map(serializeNode).join(''); } return node.text; } // ── 직렬화: Slate 값 → 마커 문자열(script) ────────────────── // bold/underline/color 마크를 경량 마커로 인코딩해 저장한다(edit_script=Slate 원본은 재편집용). // agent 는 이 문자열을 불투명 텍스트로 전달하고, 각 프론트 emphasis.tsx 가 마커를 렌더한다. // 고정 팔레트(검정/빨강/파랑) → 시맨틱 토큰. 검정(#151515)은 기본이라 마커 없음. const COLOR_TO_TOKEN: Record = { '#ED2024': '강조', // 빨강 '#4880EF': '안내', // 파랑 }; export function serializeToMarker(nodes: Descendant[]): string { return nodes.map(serializeMarkerNode).join('\n'); } function serializeMarkerNode(node: Descendant): string { if (SlateElement.isElement(node)) { if (node.type === 'variable') { // 조건 전략: 저장 위치에 조건 내용(마커 포함)을 주입 → agent 는 이미 완성된 문구를 읽는다. // 미작성 시 토큰 유지(폴백). 그 외 변수는 {name} 토큰 그대로. if (isConditionVariable(node.name)) { return node.condition && node.condition.length ? serializeToMarker(node.condition) : applyMarks(`{${node.name}}`, node.children[0]); } return applyMarks(`{${node.name}}`, node.children[0]); } return node.children.map(serializeMarkerNode).join(''); } return applyMarks(node.text, node); } // 텍스트/변수 리프의 마크를 마커로 감싼다. 중첩 순서: 색(바깥) → 굵게 → 밑줄(안). 빈 문자열은 그대로. function applyMarks(text: string, leaf?: CustomText): string { if (!text || !leaf) return text; let s = text; if (leaf.underline) s = `__${s}__`; if (leaf.bold) s = `**${s}**`; const token = leaf.color ? COLOR_TO_TOKEN[leaf.color] : undefined; if (token) s = `{{${token}|${s}}}`; return s; } // ── 역직렬화: edit_script(JSON) 또는 평문(script) → Slate 값 ── // 저장된 edit_script 가 있으면 그대로, 없으면(레거시 평문 카드) {name} 토큰을 변수 노드로 복원. // Slate 는 initialValue 를 레퍼런스로 직접 변경하므로 항상 새 배열을 돌려준다 // (react-query 캐시·공용 상수 오염 방지). export function deserialize(editScript?: unknown, plainText?: string): Descendant[] { if (Array.isArray(editScript) && editScript.length > 0) { return JSON.parse(JSON.stringify(editScript)) as Descendant[]; } if (plainText && plainText.trim()) return textToNodes(plainText); return cloneEmpty(); } function textToNodes(text: string): Descendant[] { return text.split('\n').map((line) => ({ type: 'paragraph' as const, children: lineToChildren(line) })); } function lineToChildren(line: string): (CustomText | VariableElement)[] { const children: (CustomText | VariableElement)[] = []; const re = /\{(\w+)\}/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(line)) !== null) { if (m.index > last) children.push({ text: line.slice(last, m.index) }); if (isKnownVariable(m[1])) children.push({ type: 'variable', name: m[1], children: [{ text: '' }] }); else children.push({ text: m[0] }); // 미등록 토큰은 평문 그대로 last = re.lastIndex; } if (last < line.length) children.push({ text: line.slice(last) }); return ensureTextEdges(children); } // 인라인 void(변수)는 앞뒤·사이에 텍스트 노드가 있어야 한다(Slate normalize 규칙 선반영). function ensureTextEdges( children: (CustomText | VariableElement)[], ): (CustomText | VariableElement)[] { const out: (CustomText | VariableElement)[] = []; for (const child of children) { const isVar = 'type' in child; const prev = out[out.length - 1]; if (isVar && (!prev || 'type' in prev)) out.push({ text: '' }); out.push(child); } const last = out[out.length - 1]; if (!out.length || (last && 'type' in last)) out.push({ text: '' }); return out; }