o2o-negosium-original/negodata/front/src/features/cards/editor/slate.ts
hbyang b41f7c67f1 [feat] negodata: 카드 에디터 조건 전략 변수 + 미리보기 변수 라벨 치환
① 카드 목록 스크립트 미리보기에서 {변수} 토큰을 한글 라벨 칩으로 렌더
   (renderCardScriptPreview) — 원시 토큰({prev_partner_price} 등) 노출 제거.
② "조건 전략"(customer_condition) 특수 변수 추가:
   - 툴바 '+ 조건 전략' → 본문에 위치 칩 삽입, 본문 아래 별도 Slate 입력창 노출
   - 조건 내용은 변수 노드에 중첩 보관(edit_script 배열 유지·하위호환) + 저장 시
     script 의 해당 위치에 주입 → agent 는 완성된 문구를 그대로 읽음(무변경)
   - 서브 에디터는 조건 전략 버튼 숨김(중첩 방지), 빈 조건 저장 차단

DB·백엔드·agent 변경 없음(edit_script 타입 unknown, 조건 노드 내 중첩).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 15:18:11 +09:00

246 lines
10 KiB
TypeScript

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<T extends Editor>(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<string, string> = {
'#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;
}