① 카드 목록 스크립트 미리보기에서 {변수} 토큰을 한글 라벨 칩으로 렌더
(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>
246 lines
10 KiB
TypeScript
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;
|
|
}
|