import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
input,
model,
signal,
untracked,
ViewEncapsulation,
type WritableSignal,
} from "@angular/core";
import { setupInvalid } from "../../core/validation/setupInvalid";
import { Editor, type AnyExtension } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { TextStyle } from "@tiptap/extension-text-style";
import Color from "@tiptap/extension-color";
import Highlight from "@tiptap/extension-highlight";
import TextAlign from "@tiptap/extension-text-align";
import Image from "@tiptap/extension-image";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { useTiptapToolbar } from "./useTiptapToolbar";
const DEFAULT_EXTENSIONS: AnyExtension[] = [
StarterKit,
TextStyle,
Color,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Image.configure({ inline: false, allowBase64: true }),
Underline,
];
@Component({
selector: "sd-tiptap-editor",
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
imports: [],
template: `
@if (!disabled()) {
@if (colorPickerMode() != undefined) {
@for (color of colorPresets; track color) {
}
}
}
`,
styles: [
/* language=SCSS */ `
sd-tiptap-editor {
display: block;
position: relative;
border: 1px solid var(--trans-lighter);
border-radius: var(--border-radius-default);
background: var(--theme-secondary-lightest);
> ._toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--gap-xs);
padding: var(--gap-xs);
border-bottom: 1px solid var(--trans-lighter);
> ._btn-group {
display: flex;
gap: 1px;
> button {
padding: var(--gap-xs) var(--gap-sm);
border: 1px solid var(--trans-lighter);
border-radius: var(--border-radius-default);
background: transparent;
cursor: pointer;
font-size: var(--font-size-default);
line-height: 1;
&._active {
background: var(--theme-primary-default);
color: var(--text-trans-rev-default);
}
&:hover {
background: var(--trans-lighter);
}
&._active:hover {
background: var(--theme-primary-default);
}
}
}
}
> ._color-picker {
display: flex;
flex-wrap: wrap;
gap: 2px;
padding: var(--gap-xs);
border-bottom: 1px solid var(--trans-lighter);
> ._color-swatch {
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--trans-lighter);
border-radius: 2px;
cursor: pointer;
padding: 0;
&._no-color {
display: flex;
align-items: center;
justify-content: center;
background: var(--control-color);
font-size: 0.625rem;
}
}
}
> ._editor-container {
padding: var(--gap-sm);
min-height: 6.25rem;
> .tiptap {
outline: none;
&:focus {
outline: none;
}
}
}
&[data-sd-disabled="true"] {
background: var(--theme-gray-lightest);
color: var(--text-trans-light);
> ._editor-container > .tiptap {
cursor: not-allowed;
}
}
&[data-sd-readonly="true"] {
> ._editor-container > .tiptap {
cursor: default;
}
}
._color-btn {
position: relative;
> ._color-indicator {
display: block;
height: 2px;
width: 100%;
position: absolute;
bottom: 2px;
left: 0;
}
}
}
`,
],
host: {
"[attr.data-sd-disabled]": "disabled()",
"[attr.data-sd-readonly]": "readonly()",
},
})
export class SdTiptapEditor {
value = model();
disabled = input(false, { transform: booleanAttribute });
readonly = input(false, { transform: booleanAttribute });
required = input(false, { transform: booleanAttribute });
placeholder = input();
validatorFn = input<(value: string | undefined) => string | undefined>();
extensions = input();
private readonly _elRef = inject>(ElementRef);
private readonly _destroyRef = inject(DestroyRef);
readonly colorPresets = [
"#000000", "#434343", "#666666", "#999999", "#b7b7b7", "#cccccc", "#d9d9d9", "#efefef",
"#f3f3f3", "#ffffff", "#980000", "#ff0000", "#ff9900", "#ffff00", "#00ff00", "#00ffff",
"#4a86e8", "#0000ff", "#9900ff", "#ff00ff", "#e6b8af", "#f4cccc", "#fce5cd", "#fff2cc",
"#d9ead3", "#d0e0e3", "#c9daf8", "#cfe2f3", "#d9d2e9", "#ead1dc", "#dd7e6b", "#ea9999",
"#f9cb9c", "#ffe599", "#b6d7a8", "#a2c4c9", "#a4c2f4", "#9fc5e8", "#b4a7d6", "#d5a6bd",
];
/** @internal -- TipTap Editor 인스턴스. 테스트 및 고급 사용자용 */
editor: WritableSignal = signal(undefined);
private readonly _toolbar = useTiptapToolbar({ editor: this.editor });
activeStates = this._toolbar.activeStates;
activeColor = this._toolbar.activeColor;
activeBgColor = this._toolbar.activeBgColor;
colorPickerMode = this._toolbar.colorPickerMode;
execCmd = this._toolbar.execCmd;
toggleColorPicker = this._toolbar.toggleColorPicker;
applyColor = this._toolbar.applyColor;
private _lastEditorHtml: string | undefined;
private _lastExtensions: AnyExtension[] | undefined;
private readonly _resolvedExtensions = computed(() => {
const custom = this.extensions();
if (custom != null) return custom;
const ph = this.placeholder();
if (ph != null) {
return [...DEFAULT_EXTENSIONS, Placeholder.configure({ placeholder: ph })];
}
return DEFAULT_EXTENSIONS;
});
constructor() {
// Single effect that tracks both extensions and value
effect(() => {
const extensions = this._resolvedExtensions();
const val = this.value();
// Recreate editor if extensions changed
if (this._lastExtensions !== extensions) {
this._lastExtensions = extensions;
this._destroyEditor();
this._createEditor(extensions, val);
return;
}
// Skip if value matches last editor output (editor-originated change)
if (val === this._lastEditorHtml) return;
// Sync value to existing editor
const currentEditor = untracked(() => this.editor());
if (currentEditor == null) return;
const currentHtml = this._getEditorHtmlFrom(currentEditor);
if (currentHtml === val) return;
currentEditor.commands.setContent(val ?? "", { emitUpdate: false });
this._lastEditorHtml = undefined;
});
// disabled/readonly → editor.setEditable()
effect(() => {
const ed = this.editor();
if (ed == null) return;
const editable = !this.disabled() && !this.readonly();
ed.setEditable(editable);
});
// setupInvalid for form validation
setupInvalid(() => {
const errorMessages: string[] = [];
if (this.value() == null) {
if (this.required()) {
errorMessages.push("값을 입력하세요.");
}
}
if (this.validatorFn()) {
const message = this.validatorFn()!(this.value());
if (message != null) {
errorMessages.push(message);
}
}
return errorMessages.join("\r\n");
});
this._destroyRef.onDestroy(() => {
this._destroyEditor();
});
}
private _createEditor(extensions: AnyExtension[], initialContent: string | undefined): void {
const container = this._elRef.nativeElement.querySelector("._editor-container");
if (container == null) return;
this.editor.set(new Editor({
element: container,
extensions,
content: initialContent ?? "",
editable: untracked(() => !this.disabled() && !this.readonly()),
onUpdate: ({ editor }) => {
const html = this._getEditorHtmlFrom(editor);
this._lastEditorHtml = html;
this.value.set(html);
},
onTransaction: () => {
this._toolbar.refreshActiveStates();
},
}));
}
private _destroyEditor(): void {
const ed = untracked(() => this.editor());
if (ed != null) {
ed.destroy();
this.editor.set(undefined);
}
this._lastEditorHtml = undefined;
}
private _getEditorHtmlFrom(editor: Editor): string | undefined {
const html = editor.getHTML();
if (editor.isEmpty) return undefined;
return html;
}
}