feat: initial rich-text-elements-ui library implementation

TipTap-powered rich text editing library with WYSIWYG editor, markdown editor,
template editor, collaboration support (Yjs), mentions, track changes,
comments, code blocks, and table insertion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Giuliano Silvestro
2026-02-15 23:33:25 +10:00
commit 30775d5a01
90 changed files with 9709 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Dependencies
node_modules/
# Build output
dist/
# Angular cache
.angular/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Misc
*.log

12
build-for-dev.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# Build the library
npm run build
# Link it locally for development
cd dist
npm link
echo "Library built and linked successfully"
echo "Run 'npm link @sda/rich-text-elements-ui' in your consuming app"

40
ng-package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"$schema": "node_modules/ng-packagr/ng-package.schema.json",
"lib": {
"entryFile": "src/index.ts",
"styleIncludePaths": ["src/styles"]
},
"dest": "dist",
"deleteDestPath": true,
"assets": [
"src/styles/**/*.scss"
],
"allowedNonPeerDependencies": [
"@tiptap/core",
"@tiptap/starter-kit",
"@tiptap/pm",
"@tiptap/extension-placeholder",
"@tiptap/extension-character-count",
"@tiptap/extension-color",
"@tiptap/extension-text-style",
"@tiptap/extension-highlight",
"@tiptap/extension-link",
"@tiptap/extension-image",
"@tiptap/extension-table",
"@tiptap/extension-table-row",
"@tiptap/extension-table-cell",
"@tiptap/extension-table-header",
"@tiptap/extension-task-list",
"@tiptap/extension-task-item",
"@tiptap/extension-underline",
"@tiptap/extension-text-align",
"@tiptap/extension-subscript",
"@tiptap/extension-superscript",
"@tiptap/extension-mention",
"@tiptap/extension-code-block-lowlight",
"@tiptap/extension-bubble-menu",
"@tiptap/extension-floating-menu",
"lowlight",
"marked"
]
}

4941
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

75
package.json Normal file
View File

@@ -0,0 +1,75 @@
{
"name": "@sda/rich-text-elements-ui",
"version": "0.1.0",
"description": "Angular components for rich text editing with WYSIWYG, markdown, templates, and collaboration powered by TipTap and @sda/base-ui",
"keywords": [
"angular",
"rich-text",
"wysiwyg",
"editor",
"markdown",
"tiptap",
"collaboration",
"templates",
"components",
"ui"
],
"repository": {
"type": "git",
"url": "https://git.sky-ai.com/ui-core-design/rich-text-elements-ui.git"
},
"license": "MIT",
"sideEffects": false,
"scripts": {
"build": "ng-packagr -p ng-package.json",
"build:dev": "./build-for-dev.sh"
},
"dependencies": {
"@tiptap/core": "^2.6.0",
"@tiptap/starter-kit": "^2.6.0",
"@tiptap/pm": "^2.6.0",
"@tiptap/extension-placeholder": "^2.6.0",
"@tiptap/extension-character-count": "^2.6.0",
"@tiptap/extension-color": "^2.6.0",
"@tiptap/extension-text-style": "^2.6.0",
"@tiptap/extension-highlight": "^2.6.0",
"@tiptap/extension-link": "^2.6.0",
"@tiptap/extension-image": "^2.6.0",
"@tiptap/extension-table": "^2.6.0",
"@tiptap/extension-table-row": "^2.6.0",
"@tiptap/extension-table-cell": "^2.6.0",
"@tiptap/extension-table-header": "^2.6.0",
"@tiptap/extension-task-list": "^2.6.0",
"@tiptap/extension-task-item": "^2.6.0",
"@tiptap/extension-underline": "^2.6.0",
"@tiptap/extension-text-align": "^2.6.0",
"@tiptap/extension-subscript": "^2.6.0",
"@tiptap/extension-superscript": "^2.6.0",
"@tiptap/extension-mention": "^2.6.0",
"@tiptap/extension-code-block-lowlight": "^2.6.0",
"@tiptap/extension-bubble-menu": "^2.6.0",
"@tiptap/extension-floating-menu": "^2.6.0",
"lowlight": "^3.1.0",
"marked": "^12.0.0"
},
"optionalDependencies": {
"yjs": "^13.6.0",
"y-prosemirror": "^1.2.0",
"@tiptap/extension-collaboration": "^2.6.0",
"@tiptap/extension-collaboration-cursor": "^2.6.0"
},
"peerDependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"@sda/base-ui": "*"
},
"devDependencies": {
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/compiler-cli": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.2.18",
"ng-packagr": "^19.1.0",
"typescript": "~5.7.2"
}
}

14
src/components/index.ts Normal file
View File

@@ -0,0 +1,14 @@
export * from './rt-editor';
export * from './rt-toolbar';
export * from './rt-bubble-menu';
export * from './rt-markdown-editor';
export * from './rt-template-editor';
export * from './rt-variable-picker';
export * from './rt-comment-sidebar';
export * from './rt-track-changes';
export * from './rt-mention';
export * from './rt-link-dialog';
export * from './rt-image-dialog';
export * from './rt-table-inserter';
export * from './rt-color-picker';
export * from './rt-code-block';

View File

@@ -0,0 +1 @@
export * from './rt-bubble-menu.component';

View File

@@ -0,0 +1,15 @@
<div class="rt-bubble-menu" #bubbleMenu>
@for (item of actions; track item.action) {
<ui-button
variant="ghost"
size="sm"
[iconOnly]="true"
[uiTooltip]="item.label"
[class.is-active]="isActive(item.action)"
(mousedown)="$event.preventDefault()"
(click)="onAction(item.action)"
>
<ui-icon [name]="item.icon" [size]="16" />
</ui-button>
}
</div>

View File

@@ -0,0 +1,18 @@
@use '../../styles/tokens';
.rt-bubble-menu {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.25rem;
background: var(--rt-bubble-bg);
border: 1px solid var(--rt-bubble-border);
border-radius: var(--rt-bubble-radius);
box-shadow: var(--rt-bubble-shadow);
// Active state override for base-ui ghost buttons
:host ::ng-deep ui-button.is-active .ui-button {
background: var(--rt-toolbar-btn-active);
color: var(--rt-toolbar-btn-active-text);
}
}

View File

@@ -0,0 +1,87 @@
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
input,
output,
signal,
inject,
ElementRef,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent, IconComponent, IconRegistry, TooltipDirective } from '@sda/base-ui';
import { BubbleMenuPlugin } from '@tiptap/extension-bubble-menu';
import type { Editor } from '@tiptap/core';
import type { RtToolbarAction } from '../../types/toolbar.types';
import { registerRtIcons } from '../../utils/icons.utils';
@Component({
selector: 'rt-bubble-menu',
standalone: true,
imports: [CommonModule, ButtonComponent, IconComponent, TooltipDirective],
templateUrl: './rt-bubble-menu.component.html',
styleUrl: './rt-bubble-menu.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtBubbleMenuComponent implements OnInit, OnDestroy {
readonly editor = input.required<Editor>();
readonly action = output<{ action: RtToolbarAction; value?: string }>();
readonly menuElement = viewChild<ElementRef<HTMLDivElement>>('bubbleMenu');
private pluginKey = 'rtBubbleMenu';
readonly visible = signal(false);
readonly actions: { action: RtToolbarAction; label: string; icon: string }[] = [
{ action: 'bold', label: 'Bold', icon: 'bold' },
{ action: 'italic', label: 'Italic', icon: 'italic' },
{ action: 'underline', label: 'Underline', icon: 'underline' },
{ action: 'strikethrough', label: 'Strikethrough', icon: 'strikethrough' },
{ action: 'code', label: 'Code', icon: 'code' },
{ action: 'link', label: 'Link', icon: 'link' },
];
constructor() {
registerRtIcons(inject(IconRegistry));
}
ngOnInit(): void {
const el = this.menuElement()?.nativeElement;
if (!el) return;
const editor = this.editor();
editor.registerPlugin(
BubbleMenuPlugin({
pluginKey: this.pluginKey,
editor,
element: el,
tippyOptions: {
duration: 150,
placement: 'top',
},
shouldShow: ({ state }) => {
const { from, to } = state.selection;
const show = from !== to;
this.visible.set(show);
return show;
},
}),
);
}
ngOnDestroy(): void {
this.editor().unregisterPlugin(this.pluginKey);
}
isActive(actionName: RtToolbarAction): boolean {
return this.editor().isActive(
actionName === 'strikethrough' ? 'strike' : actionName,
);
}
onAction(actionName: RtToolbarAction): void {
this.action.emit({ action: actionName });
}
}

View File

@@ -0,0 +1 @@
export * from './rt-code-block.component';

View File

@@ -0,0 +1,14 @@
<div class="rt-code-block">
<div class="rt-code-block__header">
<ui-select
size="sm"
placeholder="Plain text"
[options]="languageOptions"
[ngModel]="language()"
(ngModelChange)="onLanguageChange($event)"
/>
</div>
<div class="rt-code-block__content">
<ng-content />
</div>
</div>

View File

@@ -0,0 +1,52 @@
@use '../../styles/tokens';
.rt-code-block {
background: var(--rt-code-bg);
border: 1px solid var(--rt-code-border);
border-radius: var(--rt-code-radius);
overflow: hidden;
&__header {
display: flex;
align-items: center;
padding: 0.375rem 0.75rem;
background: var(--rt-toolbar-bg);
border-bottom: 1px solid var(--rt-code-border);
}
&__select {
border: 1px solid var(--rt-code-border);
border-radius: var(--rt-toolbar-btn-radius);
background: transparent;
color: var(--rt-editor-text);
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
cursor: pointer;
outline: none;
&:focus-visible {
border-color: var(--rt-editor-border-focus);
}
}
&__content {
padding: 0.75rem 1rem;
font-family: var(--rt-code-font-family);
font-size: var(--rt-code-font-size);
line-height: 1.5;
overflow-x: auto;
pre {
margin: 0;
background: none;
border: none;
padding: 0;
}
code {
background: none;
padding: 0;
color: inherit;
}
}
}

View File

@@ -0,0 +1,37 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SelectComponent, type SelectOption } from '@sda/base-ui';
import { RICH_TEXT_CONFIG } from '../../providers/rich-text-config.provider';
@Component({
selector: 'rt-code-block',
standalone: true,
imports: [CommonModule, FormsModule, SelectComponent],
templateUrl: './rt-code-block.component.html',
styleUrl: './rt-code-block.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtCodeBlockComponent {
private config = inject(RICH_TEXT_CONFIG);
readonly language = input('');
readonly languageChange = output<string>();
readonly languages = this.config.codeLanguages;
readonly languageOptions: SelectOption[] = [
{ value: '', label: 'Plain text' },
...this.config.codeLanguages.map(lang => ({ value: lang, label: lang })),
];
onLanguageChange(value: string): void {
this.languageChange.emit(value);
}
}

View File

@@ -0,0 +1 @@
export * from './rt-color-picker.component';

View File

@@ -0,0 +1,30 @@
@if (open()) {
<div class="rt-color-picker">
<div class="rt-color-picker__swatches">
@for (color of presetColors; track color) {
<button
type="button"
class="rt-color-picker__swatch"
[class.is-active]="currentColor() === color"
[style.background-color]="color"
[uiTooltip]="color"
(click)="onPresetClick(color)"
></button>
}
</div>
<div class="rt-color-picker__custom">
<input
type="color"
class="rt-color-picker__input"
[ngModel]="customColor()"
(ngModelChange)="customColor.set($event)"
/>
<ui-button variant="primary" size="sm" (click)="onCustomApply()">Apply</ui-button>
</div>
<div class="rt-color-picker__actions">
<ui-button variant="ghost" size="sm" (click)="onClear()">Clear color</ui-button>
</div>
</div>
}

View File

@@ -0,0 +1,85 @@
@use '../../styles/tokens';
.rt-color-picker {
background: var(--rt-editor-bg);
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-editor-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 0.75rem;
width: 200px;
&__swatches {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 3px;
margin-bottom: 0.5rem;
}
&__swatch {
width: 24px;
height: 24px;
border: 1px solid var(--rt-editor-border);
border-radius: 3px;
cursor: pointer;
padding: 0;
transition: transform var(--rt-transition);
&:hover {
transform: scale(1.15);
}
&.is-active {
outline: 2px solid var(--rt-editor-border-focus);
outline-offset: 1px;
}
}
&__custom {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--rt-editor-border);
}
&__input {
width: 2rem;
height: 2rem;
border: 1px solid var(--rt-editor-border);
border-radius: 3px;
cursor: pointer;
padding: 0;
background: none;
}
&__apply {
flex: 1;
padding: 0.25rem 0.5rem;
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-toolbar-btn-radius);
background: transparent;
color: var(--rt-editor-text);
font-size: 0.75rem;
cursor: pointer;
&:hover { background: var(--rt-toolbar-btn-hover); }
}
&__actions {
padding-top: 0.5rem;
border-top: 1px solid var(--rt-editor-border);
}
&__clear {
width: 100%;
padding: 0.25rem;
border: none;
background: transparent;
color: var(--color-error-500, #ef4444);
font-size: 0.75rem;
cursor: pointer;
&:hover { text-decoration: underline; }
}
}

View File

@@ -0,0 +1,54 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@sda/base-ui';
import { TooltipDirective } from '@sda/base-ui';
const PRESET_COLORS = [
'#000000', '#434343', '#666666', '#999999', '#cccccc', '#ffffff',
'#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#8b5cf6',
'#dc2626', '#ea580c', '#ca8a04', '#16a34a', '#2563eb', '#7c3aed',
'#991b1b', '#9a3412', '#854d0e', '#166534', '#1d4ed8', '#5b21b6',
];
@Component({
selector: 'rt-color-picker',
standalone: true,
imports: [CommonModule, FormsModule, ButtonComponent, TooltipDirective],
templateUrl: './rt-color-picker.component.html',
styleUrl: './rt-color-picker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtColorPickerComponent {
readonly open = input(false);
readonly currentColor = input<string | null>(null);
readonly colorSelect = output<string>();
readonly colorClear = output<void>();
readonly close = output<void>();
readonly customColor = signal('#000000');
readonly presetColors = PRESET_COLORS;
onPresetClick(color: string): void {
this.colorSelect.emit(color);
}
onCustomApply(): void {
this.colorSelect.emit(this.customColor());
}
onClear(): void {
this.colorClear.emit();
}
onClose(): void {
this.close.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './rt-comment-sidebar.component';

View File

@@ -0,0 +1,106 @@
@if (open()) {
<div class="rt-comment-sidebar">
<div class="rt-comment-sidebar__header">
<h3 class="rt-comment-sidebar__title">Comments</h3>
<div class="rt-comment-sidebar__filters">
<ui-button
variant="ghost"
size="sm"
[class.is-active]="filter() === 'active'"
(click)="setFilter('active')"
>Active ({{ activeCount() }})</ui-button>
<ui-button
variant="ghost"
size="sm"
[class.is-active]="filter() === 'resolved'"
(click)="setFilter('resolved')"
>Resolved ({{ resolvedCount() }})</ui-button>
<ui-button
variant="ghost"
size="sm"
[class.is-active]="filter() === 'all'"
(click)="setFilter('all')"
>All</ui-button>
</div>
</div>
<div class="rt-comment-sidebar__list">
@for (comment of filteredComments(); track comment.id) {
<ui-card padding="sm" class="rt-comment-sidebar__comment" [class.is-resolved]="comment.resolved">
<div class="rt-comment-sidebar__comment-header">
<div class="rt-comment-sidebar__author">
<span
class="rt-comment-sidebar__avatar"
[style.background-color]="comment.author.color"
>{{ comment.author.name[0] }}</span>
<span class="rt-comment-sidebar__author-name">{{ comment.author.name }}</span>
</div>
<span class="rt-comment-sidebar__time">{{ formatDate(comment.createdAt) }}</span>
</div>
@if (comment.quotedText) {
<div class="rt-comment-sidebar__quote">{{ comment.quotedText }}</div>
}
<div class="rt-comment-sidebar__content">{{ comment.content }}</div>
<!-- Replies -->
@for (reply of comment.replies; track reply.id) {
<div class="rt-comment-sidebar__reply">
<div class="rt-comment-sidebar__reply-header">
<span
class="rt-comment-sidebar__avatar rt-comment-sidebar__avatar--sm"
[style.background-color]="reply.author.color"
>{{ reply.author.name[0] }}</span>
<span class="rt-comment-sidebar__author-name">{{ reply.author.name }}</span>
<span class="rt-comment-sidebar__time">{{ formatDate(reply.createdAt) }}</span>
</div>
<div class="rt-comment-sidebar__reply-content">{{ reply.content }}</div>
</div>
}
<!-- Reply form -->
@if (replyingTo() === comment.id) {
<div class="rt-comment-sidebar__reply-form">
<ui-textarea
placeholder="Write a reply..."
size="sm"
[rows]="2"
resize="none"
[ngModel]="replyText()"
(ngModelChange)="replyText.set($event)"
/>
<div class="rt-comment-sidebar__reply-actions">
<ui-button variant="ghost" size="sm" (click)="cancelReply()">Cancel</ui-button>
<ui-button variant="primary" size="sm" [disabled]="!replyText().trim()" (click)="submitReply(comment)">Reply</ui-button>
</div>
</div>
}
<!-- Actions -->
<div class="rt-comment-sidebar__actions">
@if (replyingTo() !== comment.id) {
<ui-button variant="ghost" size="sm" (click)="startReply(comment.id)">Reply</ui-button>
}
@if (!comment.resolved) {
<ui-button variant="ghost" size="sm" (click)="onResolve(comment)">
<ui-icon name="check" [size]="12" /> Resolve
</ui-button>
} @else {
<ui-button variant="ghost" size="sm" (click)="onReopen(comment)">Reopen</ui-button>
}
<ui-button variant="danger" size="sm" (click)="onDelete(comment)">
<ui-icon name="trash" [size]="12" /> Delete
</ui-button>
</div>
</ui-card>
}
@if (filteredComments().length === 0) {
<div class="rt-comment-sidebar__empty">
No {{ filter() === 'all' ? '' : filter() }} comments
</div>
}
</div>
</div>
}

View File

@@ -0,0 +1,227 @@
@use '../../styles/tokens';
.rt-comment-sidebar {
background: var(--rt-comment-bg);
border: 1px solid var(--rt-comment-border);
border-radius: var(--rt-editor-radius);
width: 320px;
max-height: 600px;
display: flex;
flex-direction: column;
&__header {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--rt-comment-border);
}
&__title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--rt-editor-text);
margin: 0 0 0.5rem;
}
&__filters {
display: flex;
gap: 0;
}
&__filter-btn {
padding: 0.25rem 0.625rem;
border: 1px solid var(--rt-comment-border);
background: transparent;
color: var(--rt-status-text);
font-size: 0.6875rem;
cursor: pointer;
transition: all var(--rt-transition);
&:first-child { border-radius: var(--rt-toolbar-btn-radius) 0 0 var(--rt-toolbar-btn-radius); }
&:last-child { border-radius: 0 var(--rt-toolbar-btn-radius) var(--rt-toolbar-btn-radius) 0; }
&:not(:first-child) { border-left: none; }
&:hover { background: var(--rt-toolbar-btn-hover); }
&.is-active {
background: var(--rt-toolbar-btn-active);
color: var(--rt-toolbar-btn-active-text);
border-color: var(--rt-toolbar-btn-active-text);
}
}
&__list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
&__comment {
padding: 0.75rem;
border: 1px solid var(--rt-comment-border);
border-radius: var(--rt-toolbar-btn-radius);
margin-bottom: 0.5rem;
transition: background var(--rt-transition);
&.is-resolved {
background: var(--rt-comment-resolved-bg);
opacity: 0.8;
}
}
&__comment-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.375rem;
}
&__author {
display: flex;
align-items: center;
gap: 0.375rem;
}
&__avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
color: #fff;
&--sm {
width: 1.125rem;
height: 1.125rem;
font-size: 0.5rem;
}
}
&__author-name {
font-size: 0.75rem;
font-weight: 600;
color: var(--rt-editor-text);
}
&__time {
font-size: 0.625rem;
color: var(--rt-status-text);
}
&__quote {
font-size: 0.75rem;
color: var(--rt-blockquote-text);
background: var(--rt-blockquote-bg);
border-left: 2px solid var(--rt-blockquote-border);
padding: 0.25rem 0.5rem;
margin-bottom: 0.375rem;
font-style: italic;
}
&__content {
font-size: 0.8125rem;
color: var(--rt-editor-text);
line-height: 1.4;
margin-bottom: 0.375rem;
}
&__reply {
padding: 0.375rem 0 0.375rem 1rem;
border-left: 2px solid var(--rt-comment-border);
margin: 0.375rem 0;
}
&__reply-header {
display: flex;
align-items: center;
gap: 0.25rem;
margin-bottom: 0.125rem;
}
&__reply-content {
font-size: 0.75rem;
color: var(--rt-editor-text);
line-height: 1.4;
}
&__reply-form {
margin-top: 0.375rem;
}
&__reply-input {
width: 100%;
padding: 0.375rem 0.5rem;
border: 1px solid var(--rt-comment-border);
border-radius: var(--rt-toolbar-btn-radius);
background: transparent;
color: var(--rt-editor-text);
font-size: 0.75rem;
resize: none;
outline: none;
&:focus { border-color: var(--rt-editor-border-focus); }
&::placeholder { color: var(--rt-editor-placeholder); }
}
&__reply-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
margin-top: 0.375rem;
}
&__actions {
display: flex;
gap: 0.5rem;
padding-top: 0.375rem;
border-top: 1px solid var(--rt-comment-border);
margin-top: 0.375rem;
}
&__action {
border: none;
background: transparent;
font-size: 0.6875rem;
color: var(--rt-toolbar-btn-active-text);
cursor: pointer;
padding: 0;
&:hover { text-decoration: underline; }
&--danger {
color: var(--color-error-500, #ef4444);
}
}
&__btn {
padding: 0.25rem 0.625rem;
border-radius: var(--rt-toolbar-btn-radius);
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--rt-transition);
border: 1px solid transparent;
&--primary {
background: var(--color-primary-500, #3b82f6);
color: #fff;
&:hover:not(:disabled) { opacity: 0.9; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
}
&--ghost {
background: transparent;
color: var(--rt-editor-text);
border-color: var(--rt-comment-border);
&:hover { background: var(--rt-toolbar-btn-hover); }
}
}
&__empty {
padding: 2rem;
text-align: center;
font-size: 0.8125rem;
color: var(--rt-status-text);
}
}

View File

@@ -0,0 +1,105 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@sda/base-ui';
import { CardComponent } from '@sda/base-ui';
import { TextareaComponent } from '@sda/base-ui';
import { BadgeComponent } from '@sda/base-ui';
import { IconComponent } from '@sda/base-ui';
import { CollaborationService } from '../../services/collaboration.service';
import type { RtComment, RtCollabUser } from '../../types/collaboration.types';
import type { RtCommentEvent } from '../../types/event.types';
@Component({
selector: 'rt-comment-sidebar',
standalone: true,
imports: [CommonModule, FormsModule, ButtonComponent, CardComponent, TextareaComponent, BadgeComponent, IconComponent],
providers: [CollaborationService],
templateUrl: './rt-comment-sidebar.component.html',
styleUrl: './rt-comment-sidebar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtCommentSidebarComponent {
protected collabService = inject(CollaborationService);
readonly comments = input<RtComment[]>([]);
readonly currentUser = input.required<RtCollabUser>();
readonly open = input(true);
readonly commentEvent = output<RtCommentEvent>();
readonly filter = signal<'active' | 'resolved' | 'all'>('active');
readonly replyingTo = signal<string | null>(null);
readonly replyText = signal('');
readonly filteredComments = computed(() => {
const allComments = this.comments();
switch (this.filter()) {
case 'active': return allComments.filter(c => !c.resolved);
case 'resolved': return allComments.filter(c => c.resolved);
default: return allComments;
}
});
readonly activeCount = computed(() => this.comments().filter(c => !c.resolved).length);
readonly resolvedCount = computed(() => this.comments().filter(c => c.resolved).length);
setFilter(filter: 'active' | 'resolved' | 'all'): void {
this.filter.set(filter);
}
onResolve(comment: RtComment): void {
this.commentEvent.emit({ type: 'resolve', comment });
}
onReopen(comment: RtComment): void {
this.commentEvent.emit({ type: 'reopen', comment });
}
onDelete(comment: RtComment): void {
this.commentEvent.emit({ type: 'delete', comment });
}
startReply(commentId: string): void {
this.replyingTo.set(commentId);
this.replyText.set('');
}
cancelReply(): void {
this.replyingTo.set(null);
this.replyText.set('');
}
submitReply(comment: RtComment): void {
const text = this.replyText().trim();
if (!text) return;
const reply = this.collabService.addReply(comment.id, this.currentUser(), text);
this.commentEvent.emit({ type: 'reply', comment, reply });
this.replyingTo.set(null);
this.replyText.set('');
}
formatDate(isoString: string): string {
const date = new Date(isoString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
}

View File

@@ -0,0 +1 @@
export * from './rt-editor.component';

View File

@@ -0,0 +1,37 @@
<div class="rt-editor" [class.rt-editor--focused]="editorState.focused()">
@if (showToolbar()) {
<rt-toolbar
[groups]="toolbarGroups()"
[activeMarks]="editorState.activeMarks()"
[activeNodes]="editorState.activeNodes()"
[canUndo]="editorState.canUndo()"
[canRedo]="editorState.canRedo()"
(action)="onToolbarAction($event.action, $event.value)"
/>
}
<div class="rt-editor__content" #editorContainer></div>
@if (showBubbleMenu() && editor()) {
<rt-bubble-menu
[editor]="editor()!"
(action)="onToolbarAction($event.action, $event.value)"
/>
}
@if (showWordCount() || showCharacterCount()) {
<div class="rt-editor__status-bar">
@if (showWordCount()) {
<span class="rt-editor__count">{{ editorState.wordCount() }} words</span>
}
@if (showCharacterCount()) {
<span class="rt-editor__count">
{{ editorState.characterCount() }} characters
@if (maxLength()) {
/ {{ maxLength() }}
}
</span>
}
</div>
}
</div>

View File

@@ -0,0 +1,41 @@
@use '../../styles/tokens';
@use '../../styles/mixins';
.rt-editor {
@include mixins.rt-container;
@include mixins.rt-card;
&--focused {
border-color: var(--rt-editor-border-focus);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
&__content {
padding: var(--rt-editor-padding);
min-height: var(--rt-editor-min-height);
cursor: text;
}
&__status-bar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 1rem;
padding: 0.375rem var(--rt-editor-padding);
background: var(--rt-status-bg);
border-top: 1px solid var(--rt-editor-border);
}
&__count {
font-size: var(--rt-status-font-size);
color: var(--rt-status-text);
}
}
// TipTap ProseMirror styling — :host must be outermost selector
// We use ::ng-deep because TipTap creates DOM outside Angular's template
:host ::ng-deep .rt-editor__content .tiptap {
@include mixins.rt-prose;
outline: none;
min-height: inherit;
}

View File

@@ -0,0 +1,296 @@
import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
input,
output,
signal,
computed,
effect,
inject,
ElementRef,
viewChild,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import TextStyle from '@tiptap/extension-text-style';
import Color from '@tiptap/extension-color';
import Highlight from '@tiptap/extension-highlight';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { common, createLowlight } from 'lowlight';
import { RICH_TEXT_CONFIG } from '../../providers/rich-text-config.provider';
import { EditorStateService } from '../../services/editor-state.service';
import { ToolbarService } from '../../services/toolbar.service';
import { HistoryService } from '../../services/history.service';
import { RtToolbarComponent } from '../rt-toolbar/rt-toolbar.component';
import { RtBubbleMenuComponent } from '../rt-bubble-menu/rt-bubble-menu.component';
import type { RtToolbarPreset, RtToolbarAction } from '../../types/toolbar.types';
import type { RtContent } from '../../types/editor.types';
import type {
RtContentChangeEvent,
RtSelectionChangeEvent,
RtFocusChangeEvent,
RtStateChangeEvent,
RtCountChangeEvent,
} from '../../types/event.types';
import type { RtLinkData, RtImageData } from '../../types/editor.types';
@Component({
selector: 'rt-editor',
standalone: true,
imports: [CommonModule, RtToolbarComponent, RtBubbleMenuComponent],
providers: [EditorStateService, ToolbarService, HistoryService],
templateUrl: './rt-editor.component.html',
styleUrl: './rt-editor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtEditorComponent implements OnInit, OnDestroy {
private config = inject(RICH_TEXT_CONFIG);
protected editorState = inject(EditorStateService);
private toolbarService = inject(ToolbarService);
// Inputs
readonly content = input<RtContent>('');
readonly editable = input(this.config.editable);
readonly placeholder = input(this.config.placeholder);
readonly maxLength = input(this.config.maxLength);
readonly toolbarPreset = input<RtToolbarPreset>(this.config.toolbarPreset);
readonly showToolbar = input(this.config.showToolbar);
readonly showBubbleMenu = input(this.config.showBubbleMenu);
readonly showWordCount = input(this.config.showWordCount);
readonly showCharacterCount = input(this.config.showCharacterCount);
// Outputs
readonly contentChange = output<RtContentChangeEvent>();
readonly selectionChange = output<RtSelectionChangeEvent>();
readonly focusChange = output<RtFocusChangeEvent>();
readonly stateChange = output<RtStateChangeEvent>();
readonly countChange = output<RtCountChangeEvent>();
readonly linkRequest = output<void>();
readonly imageRequest = output<void>();
readonly tableRequest = output<void>();
readonly colorRequest = output<RtToolbarAction>();
// Template refs
readonly editorContainer = viewChild<ElementRef<HTMLDivElement>>('editorContainer');
// Editor instance
readonly editor = signal<Editor | null>(null);
// Toolbar groups
readonly toolbarGroups = computed(() => this.toolbarService.getPresetGroups(this.toolbarPreset()));
constructor() {
// React to content input changes
effect(() => {
const content = this.content();
const editor = this.editor();
if (editor && content !== undefined) {
const currentHtml = editor.getHTML();
if (typeof content === 'string' && content !== currentHtml) {
editor.commands.setContent(content, false);
} else if (typeof content === 'object') {
editor.commands.setContent(content, false);
}
}
});
// React to editable changes
effect(() => {
const editable = this.editable();
const editor = this.editor();
if (editor) {
editor.setEditable(editable);
}
});
}
ngOnInit(): void {
const lowlight = createLowlight(common);
const extensions = [
StarterKit.configure({
codeBlock: false, // Use CodeBlockLowlight instead
}),
Placeholder.configure({ placeholder: this.placeholder() }),
CharacterCount.configure({
limit: this.maxLength() || undefined,
}),
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TextStyle,
Color,
Highlight.configure({ multicolor: true }),
Link.configure({
openOnClick: false,
autolink: this.config.autoLink,
HTMLAttributes: { rel: 'noopener noreferrer' },
}),
Image.configure({ inline: false, allowBase64: true }),
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
TaskList,
TaskItem.configure({ nested: true }),
Subscript,
Superscript,
CodeBlockLowlight.configure({ lowlight }),
];
const container = this.editorContainer()?.nativeElement;
if (!container) return;
const editor = new Editor({
element: container,
extensions,
content: this.content() || '',
editable: this.editable(),
onUpdate: ({ editor: e }) => this.onEditorUpdate(e),
onSelectionUpdate: ({ editor: e }) => this.onEditorSelectionUpdate(e),
onFocus: () => this.onEditorFocus(),
onBlur: () => this.onEditorBlur(),
});
this.editor.set(editor);
this.editorState.updateFromEditor(editor);
}
ngOnDestroy(): void {
this.editor()?.destroy();
}
/** Handle toolbar action */
onToolbarAction(action: RtToolbarAction, value?: string): void {
const editor = this.editor();
if (!editor) return;
if (action === 'link') {
this.linkRequest.emit();
return;
}
if (action === 'image') {
this.imageRequest.emit();
return;
}
if (action === 'table') {
this.tableRequest.emit();
return;
}
if (action === 'text-color' || action === 'highlight-color') {
this.colorRequest.emit(action);
return;
}
this.toolbarService.executeAction(editor, action, value);
}
/** Set a link on the current selection */
setLink(linkData: RtLinkData): void {
const editor = this.editor();
if (!editor) return;
if (linkData.href) {
editor.chain().focus()
.extendMarkRange('link')
.setLink({ href: linkData.href, target: linkData.target ?? '_blank' })
.run();
} else {
editor.chain().focus().unsetLink().run();
}
}
/** Insert an image */
setImage(imageData: RtImageData): void {
const editor = this.editor();
if (!editor) return;
editor.chain().focus().setImage(imageData).run();
}
/** Insert a table */
insertTable(rows: number, cols: number): void {
const editor = this.editor();
if (!editor) return;
editor.chain().focus().insertTable({ rows, cols, withHeaderRow: true }).run();
}
/** Set text or highlight color */
setColor(action: RtToolbarAction, color: string): void {
const editor = this.editor();
if (!editor) return;
this.toolbarService.executeAction(editor, action, color);
}
private onEditorUpdate(editor: Editor): void {
this.editorState.updateFromEditor(editor);
this.contentChange.emit({
html: editor.getHTML(),
json: editor.getJSON(),
text: editor.getText(),
isEmpty: editor.isEmpty,
});
this.stateChange.emit({
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
activeMarks: this.editorState.activeMarks(),
activeNodes: this.editorState.activeNodes(),
});
this.countChange.emit({
words: this.editorState.wordCount(),
characters: this.editorState.characterCount(),
});
}
private onEditorSelectionUpdate(editor: Editor): void {
this.editorState.updateFromEditor(editor);
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
this.selectionChange.emit({
from,
to,
hasSelection: from !== to,
selectedText,
activeMarks: this.editorState.activeMarks(),
activeNodes: this.editorState.activeNodes(),
});
this.stateChange.emit({
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
activeMarks: this.editorState.activeMarks(),
activeNodes: this.editorState.activeNodes(),
});
}
private onEditorFocus(): void {
this.editorState.setFocused(true);
this.focusChange.emit({ focused: true });
}
private onEditorBlur(): void {
this.editorState.setFocused(false);
this.focusChange.emit({ focused: false });
}
}

View File

@@ -0,0 +1 @@
export * from './rt-image-dialog.component';

View File

@@ -0,0 +1,39 @@
<ui-modal [open]="open()" title="Insert Image" size="sm" (closed)="onCancel()">
<div class="rt-image-dialog__body">
<ui-input
label="Image URL"
type="url"
placeholder="https://example.com/image.jpg"
[ngModel]="src()"
(ngModelChange)="src.set($event)"
/>
<ui-input
label="Alt text"
placeholder="Describe the image"
[ngModel]="alt()"
(ngModelChange)="alt.set($event)"
/>
<div class="rt-image-dialog__row">
<ui-input
label="Width (px)"
type="number"
placeholder="Auto"
[ngModel]="width()"
(ngModelChange)="width.set($event)"
/>
<ui-input
label="Height (px)"
type="number"
placeholder="Auto"
[ngModel]="height()"
(ngModelChange)="height.set($event)"
/>
</div>
</div>
<div class="rt-image-dialog__footer">
<div class="rt-image-dialog__spacer"></div>
<ui-button variant="ghost" size="sm" (click)="onCancel()">Cancel</ui-button>
<ui-button variant="primary" size="sm" [disabled]="!src().trim()" (click)="onConfirm()">Insert</ui-button>
</div>
</ui-modal>

View File

@@ -0,0 +1,24 @@
.rt-image-dialog {
&__body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
}
&__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
&__footer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--rt-editor-border);
}
&__spacer { flex: 1; }
}

View File

@@ -0,0 +1,57 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ModalComponent } from '@sda/base-ui';
import { InputComponent } from '@sda/base-ui';
import { ButtonComponent } from '@sda/base-ui';
import type { RtImageData } from '../../types/editor.types';
@Component({
selector: 'rt-image-dialog',
standalone: true,
imports: [CommonModule, FormsModule, ModalComponent, InputComponent, ButtonComponent],
templateUrl: './rt-image-dialog.component.html',
styleUrl: './rt-image-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtImageDialogComponent {
readonly open = input(false);
readonly confirm = output<RtImageData>();
readonly cancel = output<void>();
readonly src = signal('');
readonly alt = signal('');
readonly width = signal<number | undefined>(undefined);
readonly height = signal<number | undefined>(undefined);
ngOnChanges(): void {
if (this.open()) {
this.src.set('');
this.alt.set('');
this.width.set(undefined);
this.height.set(undefined);
}
}
onConfirm(): void {
const src = this.src().trim();
if (!src) return;
this.confirm.emit({
src,
alt: this.alt().trim() || undefined,
width: this.width(),
height: this.height(),
});
}
onCancel(): void {
this.cancel.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './rt-link-dialog.component';

View File

@@ -0,0 +1,31 @@
<ui-modal [open]="open()" title="Insert Link" size="sm" (closed)="onCancel()">
<div class="rt-link-dialog__body">
<ui-input
label="URL"
type="url"
placeholder="https://example.com"
[ngModel]="url()"
(ngModelChange)="url.set($event)"
/>
<ui-input
label="Text (optional)"
placeholder="Link text"
[ngModel]="text()"
(ngModelChange)="text.set($event)"
/>
<ui-toggle
label="Open in new tab"
[ngModel]="openInNewTab()"
(ngModelChange)="openInNewTab.set($event)"
/>
</div>
<div class="rt-link-dialog__footer">
@if (initialUrl()) {
<ui-button variant="danger" size="sm" (click)="onRemove()">Remove Link</ui-button>
}
<div class="rt-link-dialog__spacer"></div>
<ui-button variant="ghost" size="sm" (click)="onCancel()">Cancel</ui-button>
<ui-button variant="primary" size="sm" [disabled]="!url().trim()" (click)="onConfirm()">Apply</ui-button>
</div>
</ui-modal>

View File

@@ -0,0 +1,20 @@
.rt-link-dialog {
&__body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
}
&__footer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--rt-editor-border);
}
&__spacer {
flex: 1;
}
}

View File

@@ -0,0 +1,61 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ModalComponent } from '@sda/base-ui';
import { InputComponent } from '@sda/base-ui';
import { ButtonComponent } from '@sda/base-ui';
import { ToggleComponent } from '@sda/base-ui';
import type { RtLinkData } from '../../types/editor.types';
@Component({
selector: 'rt-link-dialog',
standalone: true,
imports: [CommonModule, FormsModule, ModalComponent, InputComponent, ButtonComponent, ToggleComponent],
templateUrl: './rt-link-dialog.component.html',
styleUrl: './rt-link-dialog.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtLinkDialogComponent {
readonly open = input(false);
readonly initialUrl = input('');
readonly initialText = input('');
readonly confirm = output<RtLinkData>();
readonly remove = output<void>();
readonly cancel = output<void>();
readonly url = signal('');
readonly text = signal('');
readonly openInNewTab = signal(true);
ngOnChanges(): void {
if (this.open()) {
this.url.set(this.initialUrl());
this.text.set(this.initialText());
}
}
onConfirm(): void {
const url = this.url().trim();
if (!url) return;
this.confirm.emit({
href: url,
text: this.text().trim() || undefined,
target: this.openInNewTab() ? '_blank' : '_self',
});
}
onRemove(): void {
this.remove.emit();
}
onCancel(): void {
this.cancel.emit();
}
}

View File

@@ -0,0 +1 @@
export * from './rt-markdown-editor.component';

View File

@@ -0,0 +1,54 @@
<div class="rt-markdown-editor">
<!-- Toolbar -->
@if (showToolbar()) {
<div class="rt-markdown-editor__header">
<rt-toolbar
[groups]="toolbarGroups()"
[activeMarks]="activeMarks()"
[activeNodes]="activeNodes()"
(action)="onToolbarAction($event)"
/>
<div class="rt-markdown-editor__view-modes">
<ui-button
variant="ghost"
size="sm"
[class.is-active]="viewMode() === 'edit'"
(click)="setViewMode('edit')"
><ui-icon name="edit" [size]="14" /> Edit</ui-button>
<ui-button
variant="ghost"
size="sm"
[class.is-active]="viewMode() === 'split'"
(click)="setViewMode('split')"
><ui-icon name="columns" [size]="14" /> Split</ui-button>
<ui-button
variant="ghost"
size="sm"
[class.is-active]="viewMode() === 'preview'"
(click)="setViewMode('preview')"
><ui-icon name="eye" [size]="14" /> Preview</ui-button>
</div>
</div>
}
<!-- Content area -->
<div class="rt-markdown-editor__content" [class]="'rt-markdown-editor__content--' + viewMode()">
@if (viewMode() !== 'preview') {
<div class="rt-markdown-editor__edit-pane">
<textarea
#markdownTextarea
class="rt-markdown-editor__textarea"
[placeholder]="placeholder()"
[ngModel]="markdown()"
(ngModelChange)="onMarkdownInput($event)"
></textarea>
</div>
}
@if (viewMode() !== 'edit') {
<div class="rt-markdown-editor__preview-pane">
<div class="rt-markdown-editor__preview" [innerHTML]="preview()"></div>
</div>
}
</div>
</div>

View File

@@ -0,0 +1,101 @@
@use '../../styles/tokens';
@use '../../styles/mixins';
.rt-markdown-editor {
@include mixins.rt-container;
@include mixins.rt-card;
&__header {
display: flex;
align-items: center;
border-bottom: 1px solid var(--rt-editor-border);
rt-toolbar {
flex: 1;
}
}
&__view-modes {
display: flex;
gap: 0;
padding: 0 0.5rem;
border-left: 1px solid var(--rt-editor-border);
}
&__mode-btn {
padding: 0.375rem 0.625rem;
border: none;
background: transparent;
color: var(--rt-status-text);
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--rt-transition);
border-bottom: 2px solid transparent;
&:hover {
color: var(--rt-editor-text);
}
&.is-active {
color: var(--rt-toolbar-btn-active-text);
border-bottom-color: var(--rt-toolbar-btn-active-text);
}
}
&__content {
display: flex;
min-height: 300px;
&--edit {
.rt-markdown-editor__edit-pane { flex: 1; }
}
&--split {
.rt-markdown-editor__edit-pane,
.rt-markdown-editor__preview-pane {
flex: 1;
width: 50%;
}
}
&--preview {
.rt-markdown-editor__preview-pane { flex: 1; }
}
}
&__edit-pane {
display: flex;
border-right: 1px solid var(--rt-editor-border);
}
&__textarea {
flex: 1;
width: 100%;
border: none;
outline: none;
resize: none;
padding: var(--rt-editor-padding);
background: transparent;
color: var(--rt-editor-text);
font-family: var(--rt-code-font-family);
font-size: 0.875rem;
line-height: 1.6;
tab-size: 2;
&::placeholder {
color: var(--rt-editor-placeholder);
}
}
&__preview-pane {
overflow-y: auto;
}
}
// Preview prose styles — :host must be outermost for ::ng-deep
// innerHTML content won't have Angular's _ngcontent attributes
:host ::ng-deep .rt-markdown-editor__preview {
@include mixins.rt-prose;
padding: var(--rt-editor-padding);
}

View File

@@ -0,0 +1,169 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
inject,
effect,
viewChild,
ElementRef,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MarkdownService } from '../../services/markdown.service';
import { ToolbarService } from '../../services/toolbar.service';
import { ButtonComponent, IconComponent, IconRegistry } from '@sda/base-ui';
import { RtToolbarComponent } from '../rt-toolbar/rt-toolbar.component';
import { registerRtIcons } from '../../utils/icons.utils';
import { wrapSelection, insertLinePrefix, insertBlock, toggleLinePrefix } from '../../utils/markdown.utils';
import type { RtToolbarAction } from '../../types/toolbar.types';
import type { RtActiveMarks, RtActiveNodes } from '../../types/editor.types';
type ViewMode = 'edit' | 'split' | 'preview';
const EMPTY_MARKS: RtActiveMarks = {
bold: false, italic: false, underline: false, strike: false,
code: false, subscript: false, superscript: false, link: false,
highlight: false, color: null, highlightColor: null,
};
const EMPTY_NODES: RtActiveNodes = {
paragraph: false, heading: null, bulletList: false, orderedList: false,
taskList: false, blockquote: false, codeBlock: false, table: false,
image: false, horizontalRule: false, textAlign: null,
};
@Component({
selector: 'rt-markdown-editor',
standalone: true,
imports: [CommonModule, FormsModule, ButtonComponent, IconComponent, RtToolbarComponent],
providers: [MarkdownService, ToolbarService],
templateUrl: './rt-markdown-editor.component.html',
styleUrl: './rt-markdown-editor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtMarkdownEditorComponent {
private markdownService = inject(MarkdownService);
private toolbarService = inject(ToolbarService);
readonly content = input('');
readonly placeholder = input('Write markdown here...');
readonly showToolbar = input(true);
readonly contentChange = output<string>();
readonly htmlChange = output<string>();
readonly markdown = signal('');
readonly viewMode = signal<ViewMode>('split');
readonly toolbarGroups = computed(() => this.toolbarService.getPresetGroups('markdown'));
readonly activeMarks = signal<RtActiveMarks>(EMPTY_MARKS);
readonly activeNodes = signal<RtActiveNodes>(EMPTY_NODES);
readonly preview = computed(() => {
const md = this.markdown();
if (!md.trim()) return '';
return this.markdownService.toHtml(md);
});
readonly textareaEl = viewChild<ElementRef<HTMLTextAreaElement>>('markdownTextarea');
constructor() {
registerRtIcons(inject(IconRegistry));
effect(() => {
const content = this.content();
if (content !== undefined) {
this.markdown.set(content);
}
});
}
onMarkdownInput(value: string): void {
this.markdown.set(value);
this.contentChange.emit(value);
this.htmlChange.emit(this.preview());
}
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
onToolbarAction(event: { action: RtToolbarAction; value?: string }): void {
const ta = this.textareaEl()?.nativeElement;
if (!ta) return;
const { action } = event;
const start = ta.selectionStart;
const end = ta.selectionEnd;
const text = this.markdown();
let result: { text: string; selectionStart?: number; selectionEnd?: number; cursorPos?: number };
switch (action) {
case 'bold':
result = wrapSelection(text, start, end, '**', '**');
break;
case 'italic':
result = wrapSelection(text, start, end, '*', '*');
break;
case 'strikethrough':
result = wrapSelection(text, start, end, '~~', '~~');
break;
case 'code':
result = wrapSelection(text, start, end, '`', '`');
break;
case 'heading-1':
result = { ...toggleLinePrefix(text, start, '# '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'heading-2':
result = { ...toggleLinePrefix(text, start, '## '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'heading-3':
result = { ...toggleLinePrefix(text, start, '### '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'bullet-list':
result = { ...insertLinePrefix(text, start, '- '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'ordered-list':
result = { ...insertLinePrefix(text, start, '1. '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'task-list':
result = { ...insertLinePrefix(text, start, '- [ ] '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'blockquote':
result = { ...insertLinePrefix(text, start, '> '), selectionStart: undefined, selectionEnd: undefined };
break;
case 'code-block':
result = insertBlock(text, start, '```', '```', 'code');
break;
case 'link':
result = wrapSelection(text, start, end, '[', '](url)');
break;
case 'image':
result = wrapSelection(text, start, end, '![', '](url)');
break;
case 'horizontal-rule':
result = { ...insertLinePrefix(text, start, '\n---\n'), selectionStart: undefined, selectionEnd: undefined };
break;
default:
return;
}
this.markdown.set(result.text);
this.contentChange.emit(result.text);
this.htmlChange.emit(this.markdownService.toHtml(result.text));
// Restore cursor position
requestAnimationFrame(() => {
if (ta) {
ta.focus();
const pos = result.selectionStart ?? result.cursorPos ?? start;
const endPos = result.selectionEnd ?? pos;
ta.setSelectionRange(pos, endPos);
}
});
}
}

View File

@@ -0,0 +1 @@
export * from './rt-mention.component';

View File

@@ -0,0 +1,29 @@
<div class="rt-mention">
@for (item of filteredItems(); track item.id; let i = $index) {
<button
type="button"
class="rt-mention__item"
[class.is-selected]="i === selectedIndex()"
(click)="onSelect(item)"
(mouseenter)="selectedIndex.set(i)"
>
<span class="rt-mention__avatar">
@if (item.avatar) {
<img [src]="item.avatar" [alt]="item.label" class="rt-mention__avatar-img" />
} @else {
{{ item.label[0] }}
}
</span>
<div class="rt-mention__info">
<span class="rt-mention__name">{{ item.label }}</span>
@if (item.email) {
<span class="rt-mention__email">{{ item.email }}</span>
}
</div>
</button>
}
@if (filteredItems().length === 0) {
<div class="rt-mention__empty">No results</div>
}
</div>

View File

@@ -0,0 +1,82 @@
@use '../../styles/tokens';
.rt-mention {
background: var(--rt-bubble-bg);
border: 1px solid var(--rt-bubble-border);
border-radius: var(--rt-editor-radius);
box-shadow: var(--rt-bubble-shadow);
padding: 0.25rem;
min-width: 200px;
max-height: 240px;
overflow-y: auto;
&__item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.5rem;
border: none;
border-radius: var(--rt-toolbar-btn-radius);
background: transparent;
cursor: pointer;
text-align: left;
transition: background var(--rt-transition);
&:hover,
&.is-selected {
background: var(--rt-toolbar-btn-hover);
}
}
&__avatar {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--color-primary-500, #3b82f6);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
flex-shrink: 0;
overflow: hidden;
}
&__avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
&__info {
display: flex;
flex-direction: column;
min-width: 0;
}
&__name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--rt-editor-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__email {
font-size: 0.6875rem;
color: var(--rt-status-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__empty {
padding: 0.75rem;
text-align: center;
font-size: 0.75rem;
color: var(--rt-status-text);
}
}

View File

@@ -0,0 +1,60 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import type { RtMentionItem } from '../../types/editor.types';
@Component({
selector: 'rt-mention',
standalone: true,
imports: [CommonModule],
templateUrl: './rt-mention.component.html',
styleUrl: './rt-mention.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtMentionComponent {
readonly items = input<RtMentionItem[]>([]);
readonly query = input('');
readonly mentionSelect = output<RtMentionItem>();
readonly selectedIndex = signal(0);
readonly filteredItems = computed(() => {
const q = this.query().toLowerCase();
if (!q) return this.items();
return this.items().filter(item =>
item.label.toLowerCase().includes(q) ||
(item.email && item.email.toLowerCase().includes(q)),
);
});
onSelect(item: RtMentionItem): void {
this.mentionSelect.emit(item);
}
onKeyDown(event: KeyboardEvent): boolean {
const items = this.filteredItems();
if (!items.length) return false;
if (event.key === 'ArrowUp') {
this.selectedIndex.update(i => (i + items.length - 1) % items.length);
return true;
}
if (event.key === 'ArrowDown') {
this.selectedIndex.update(i => (i + 1) % items.length);
return true;
}
if (event.key === 'Enter') {
const item = items[this.selectedIndex()];
if (item) this.onSelect(item);
return true;
}
return false;
}
}

View File

@@ -0,0 +1 @@
export * from './rt-table-inserter.component';

View File

@@ -0,0 +1,26 @@
@if (open()) {
<div class="rt-table-inserter" (mouseleave)="onMouseLeave()">
<div class="rt-table-inserter__grid">
@for (row of rows; track row) {
<div class="rt-table-inserter__row">
@for (col of cols; track col) {
<button
type="button"
class="rt-table-inserter__cell"
[class.is-highlighted]="isHighlighted(row, col)"
(mouseenter)="onCellHover(row, col)"
(click)="onCellClick(row, col)"
></button>
}
</div>
}
</div>
<div class="rt-table-inserter__label">
@if (hoverRow() > 0 && hoverCol() > 0) {
{{ hoverRow() }} x {{ hoverCol() }}
} @else {
Select size
}
</div>
</div>
}

View File

@@ -0,0 +1,47 @@
@use '../../styles/tokens';
.rt-table-inserter {
background: var(--rt-editor-bg);
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-editor-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
padding: 0.5rem;
&__grid {
display: flex;
flex-direction: column;
gap: 2px;
}
&__row {
display: flex;
gap: 2px;
}
&__cell {
width: 1.25rem;
height: 1.25rem;
border: 1px solid var(--rt-editor-border);
border-radius: 2px;
background: transparent;
cursor: pointer;
padding: 0;
transition: all var(--rt-transition);
&:hover {
border-color: var(--rt-editor-border-focus);
}
&.is-highlighted {
background: var(--rt-toolbar-btn-active);
border-color: var(--rt-toolbar-btn-active-text);
}
}
&__label {
text-align: center;
font-size: 0.6875rem;
color: var(--rt-status-text);
margin-top: 0.375rem;
}
}

View File

@@ -0,0 +1,49 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'rt-table-inserter',
standalone: true,
imports: [CommonModule],
templateUrl: './rt-table-inserter.component.html',
styleUrl: './rt-table-inserter.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtTableInserterComponent {
readonly open = input(false);
readonly confirm = output<{ rows: number; cols: number }>();
readonly cancel = output<void>();
readonly maxRows = 8;
readonly maxCols = 6;
readonly hoverRow = signal(0);
readonly hoverCol = signal(0);
readonly rows = Array.from({ length: 8 }, (_, i) => i + 1);
readonly cols = Array.from({ length: 6 }, (_, i) => i + 1);
onCellHover(row: number, col: number): void {
this.hoverRow.set(row);
this.hoverCol.set(col);
}
onCellClick(row: number, col: number): void {
this.confirm.emit({ rows: row, cols: col });
}
onMouseLeave(): void {
this.hoverRow.set(0);
this.hoverCol.set(0);
}
isHighlighted(row: number, col: number): boolean {
return row <= this.hoverRow() && col <= this.hoverCol();
}
}

View File

@@ -0,0 +1 @@
export * from './rt-template-editor.component';

View File

@@ -0,0 +1,32 @@
<div class="rt-template-editor">
<div class="rt-template-editor__toolbar-extra">
<ui-button
variant="ghost"
size="sm"
[class.is-active]="variablePickerOpen()"
(click)="toggleVariablePicker()"
>
{{ '{' }}{{ '{' }} {{ '}' }}{{ '}' }} Variables
</ui-button>
@if (variablePickerOpen()) {
<div class="rt-template-editor__picker-dropdown">
<rt-variable-picker
[variables]="variables()"
(variableSelect)="onVariableSelect($event)"
(close)="variablePickerOpen.set(false)"
/>
</div>
}
</div>
<rt-editor
#templateEditorRef
[content]="content()"
[toolbarPreset]="toolbarPreset()"
[placeholder]="placeholder()"
[showToolbar]="true"
[showBubbleMenu]="true"
(contentChange)="onContentChange($event)"
/>
</div>

View File

@@ -0,0 +1,54 @@
@use '../../styles/tokens';
.rt-template-editor {
position: relative;
&__toolbar-extra {
position: relative;
display: flex;
align-items: center;
padding: 0.375rem 0.5rem;
background: var(--rt-toolbar-bg);
border: 1px solid var(--rt-editor-border);
border-bottom: none;
border-radius: var(--rt-editor-radius) var(--rt-editor-radius) 0 0;
}
&__var-btn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--rt-var-border);
border-radius: var(--rt-var-radius);
background: var(--rt-var-bg);
color: var(--rt-var-text);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all var(--rt-transition);
&:hover {
opacity: 0.85;
}
&.is-active {
background: var(--rt-toolbar-btn-active);
}
}
&__picker-dropdown {
position: absolute;
top: 100%;
left: 0.5rem;
z-index: 100;
margin-top: 0.25rem;
}
rt-editor {
display: block;
// Remove top border-radius since we have the toolbar-extra above
::ng-deep .rt-editor {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
}

View File

@@ -0,0 +1,80 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
inject,
effect,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent } from '@sda/base-ui';
import { RtEditorComponent } from '../rt-editor/rt-editor.component';
import { RtVariablePickerComponent } from '../rt-variable-picker/rt-variable-picker.component';
import { TemplateService } from '../../services/template.service';
import type { RtTemplateVariable, RtTemplateContext } from '../../types/template.types';
import type { RtContentChangeEvent } from '../../types/event.types';
import type { RtToolbarPreset } from '../../types/toolbar.types';
import { buildVariablePlaceholder } from '../../utils/template.utils';
@Component({
selector: 'rt-template-editor',
standalone: true,
imports: [CommonModule, ButtonComponent, RtEditorComponent, RtVariablePickerComponent],
providers: [TemplateService],
templateUrl: './rt-template-editor.component.html',
styleUrl: './rt-template-editor.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtTemplateEditorComponent {
private templateService = inject(TemplateService);
readonly content = input('');
readonly variables = input<RtTemplateVariable[]>([]);
readonly toolbarPreset = input<RtToolbarPreset>('standard');
readonly placeholder = input('Write your template...');
readonly contentChange = output<RtContentChangeEvent>();
readonly variableInsert = output<RtTemplateVariable>();
readonly variablePickerOpen = signal(false);
private editorRef: RtEditorComponent | null = null;
constructor() {
effect(() => {
this.templateService.setVariables(this.variables());
});
}
onEditorRef(editor: RtEditorComponent): void {
this.editorRef = editor;
}
onContentChange(event: RtContentChangeEvent): void {
this.contentChange.emit(event);
}
toggleVariablePicker(): void {
this.variablePickerOpen.update(v => !v);
}
onVariableSelect(variable: RtTemplateVariable): void {
const placeholder = buildVariablePlaceholder(variable.key);
const editor = this.editorRef?.editor();
if (editor) {
editor.chain().focus().insertContent(
`<span data-type="variable" data-id="${variable.key}" data-label="${variable.label}">${placeholder}</span>&nbsp;`
).run();
}
this.variableInsert.emit(variable);
this.variablePickerOpen.set(false);
}
/** Resolve all variables and return the final content */
resolveTemplate(context: RtTemplateContext): string {
const editor = this.editorRef?.editor();
if (!editor) return '';
return this.templateService.resolve(editor.getHTML(), context);
}
}

View File

@@ -0,0 +1 @@
export * from './rt-toolbar.component';

View File

@@ -0,0 +1,34 @@
<div class="rt-toolbar" role="toolbar" aria-label="Text formatting">
@for (group of groups(); track group.label; let last = $last) {
<div class="rt-toolbar__group" role="group" [attr.aria-label]="group.label">
@for (item of group.items; track item.action) {
@if (item.type === 'separator') {
<div class="rt-toolbar__separator"></div>
} @else if (item.type === 'select') {
<ui-select
class="rt-toolbar__select"
size="sm"
[options]="headingOptions"
[ngModel]="getCurrentHeadingValue()"
(ngModelChange)="onHeadingChange($event)"
/>
} @else {
<ui-button
variant="ghost"
size="sm"
[iconOnly]="true"
[disabled]="isDisabled(item.action)"
[uiTooltip]="item.label"
[class.is-active]="isActive(item.action)"
(click)="onAction(item.action)"
>
<ui-icon [name]="item.icon!" [size]="16" />
</ui-button>
}
}
</div>
@if (!last) {
<div class="rt-toolbar__separator"></div>
}
}
</div>

View File

@@ -0,0 +1,34 @@
@use '../../styles/tokens';
.rt-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--rt-toolbar-gap);
padding: var(--rt-toolbar-padding);
background: var(--rt-toolbar-bg);
border-bottom: 1px solid var(--rt-toolbar-border);
&__group {
display: flex;
align-items: center;
gap: var(--rt-toolbar-gap);
}
&__separator {
width: 1px;
height: 1.25rem;
background: var(--rt-toolbar-separator);
margin: 0 0.125rem;
}
&__select {
min-width: 120px;
}
// Active state override for base-ui ghost buttons
:host ::ng-deep ui-button.is-active .btn {
background: var(--rt-toolbar-btn-active);
color: var(--rt-toolbar-btn-active-text);
}
}

View File

@@ -0,0 +1,96 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ButtonComponent, IconComponent, IconRegistry, SelectComponent, type SelectOption, TooltipDirective } from '@sda/base-ui';
import type { RtToolbarGroup, RtToolbarAction } from '../../types/toolbar.types';
import type { RtActiveMarks, RtActiveNodes } from '../../types/editor.types';
import { registerRtIcons } from '../../utils/icons.utils';
@Component({
selector: 'rt-toolbar',
standalone: true,
imports: [CommonModule, FormsModule, ButtonComponent, IconComponent, SelectComponent, TooltipDirective],
templateUrl: './rt-toolbar.component.html',
styleUrl: './rt-toolbar.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtToolbarComponent {
readonly groups = input.required<RtToolbarGroup[]>();
readonly activeMarks = input.required<RtActiveMarks>();
readonly activeNodes = input.required<RtActiveNodes>();
readonly canUndo = input(false);
readonly canRedo = input(false);
readonly action = output<{ action: RtToolbarAction; value?: string }>();
readonly headingOptions: SelectOption[] = [
{ value: '0', label: 'Normal text' },
{ value: '1', label: 'Heading 1' },
{ value: '2', label: 'Heading 2' },
{ value: '3', label: 'Heading 3' },
{ value: '4', label: 'Heading 4' },
];
constructor() {
registerRtIcons(inject(IconRegistry));
}
/** Check if a toolbar action is currently active */
isActive(actionName: RtToolbarAction): boolean {
const marks = this.activeMarks();
const nodes = this.activeNodes();
switch (actionName) {
case 'bold': return marks.bold;
case 'italic': return marks.italic;
case 'underline': return marks.underline;
case 'strikethrough': return marks.strike;
case 'code': return marks.code;
case 'subscript': return marks.subscript;
case 'superscript': return marks.superscript;
case 'link': return marks.link;
case 'highlight-color': return marks.highlight;
case 'bullet-list': return nodes.bulletList;
case 'ordered-list': return nodes.orderedList;
case 'task-list': return nodes.taskList;
case 'blockquote': return nodes.blockquote;
case 'code-block': return nodes.codeBlock;
case 'align-left': return nodes.textAlign === 'left';
case 'align-center': return nodes.textAlign === 'center';
case 'align-right': return nodes.textAlign === 'right';
case 'align-justify': return nodes.textAlign === 'justify';
default: return false;
}
}
/** Check if a toolbar action is disabled */
isDisabled(actionName: RtToolbarAction): boolean {
switch (actionName) {
case 'undo': return !this.canUndo();
case 'redo': return !this.canRedo();
default: return false;
}
}
/** Get the current heading level as a string value */
getCurrentHeadingValue(): string {
const heading = this.activeNodes().heading;
return heading ? String(heading) : '0';
}
/** Emit action event */
onAction(actionName: RtToolbarAction, value?: string): void {
this.action.emit({ action: actionName, value });
}
/** Handle heading select change */
onHeadingChange(value: string | number): void {
this.action.emit({ action: 'heading', value: String(value) });
}
}

View File

@@ -0,0 +1 @@
export * from './rt-track-changes.component';

View File

@@ -0,0 +1,52 @@
<div class="rt-track-changes">
@if (enabled() && changes().length > 0) {
<div class="rt-track-changes__header">
<ui-badge variant="info" size="sm">{{ changes().length }} changes</ui-badge>
<div class="rt-track-changes__bulk-actions">
<ui-button variant="ghost" size="sm" (click)="onAcceptAll()">
<ui-icon name="check" [size]="12" /> Accept All
</ui-button>
<ui-button variant="danger" size="sm" (click)="onRejectAll()">
<ui-icon name="close" [size]="12" /> Reject All
</ui-button>
</div>
</div>
<div class="rt-track-changes__list">
@for (change of changes(); track change.id) {
<div class="rt-track-changes__change" [class]="'rt-track-changes__change--' + change.type">
<div class="rt-track-changes__change-header">
<span
class="rt-track-changes__avatar"
[style.background-color]="change.author.color"
>{{ change.author.name[0] }}</span>
<span class="rt-track-changes__author">{{ change.author.name }}</span>
<ui-badge
[variant]="change.type === 'insertion' ? 'success' : 'error'"
size="sm"
>{{ change.type }}</ui-badge>
</div>
<div class="rt-track-changes__change-content">
<span [class]="change.type === 'insertion' ? 'rt-track-changes__inserted' : 'rt-track-changes__deleted'">
{{ change.content }}
</span>
</div>
<div class="rt-track-changes__change-actions">
<ui-button variant="ghost" size="sm" (click)="onAccept(change)">
<ui-icon name="check" [size]="12" /> Accept
</ui-button>
<ui-button variant="danger" size="sm" (click)="onReject(change)">
<ui-icon name="close" [size]="12" /> Reject
</ui-button>
</div>
</div>
}
</div>
}
@if (enabled() && changes().length === 0) {
<div class="rt-track-changes__empty">
No tracked changes
</div>
}
</div>

View File

@@ -0,0 +1,142 @@
@use '../../styles/tokens';
.rt-track-changes {
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--rt-toolbar-bg);
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-editor-radius) var(--rt-editor-radius) 0 0;
}
&__count {
font-size: 0.75rem;
font-weight: 600;
color: var(--rt-editor-text);
}
&__bulk-actions {
display: flex;
gap: 0.375rem;
}
&__list {
border: 1px solid var(--rt-editor-border);
border-top: none;
border-radius: 0 0 var(--rt-editor-radius) var(--rt-editor-radius);
overflow: hidden;
}
&__change {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--rt-editor-border);
&:last-child { border-bottom: none; }
&--insertion {
background: var(--rt-insertion-bg);
}
&--deletion {
background: var(--rt-deletion-bg);
}
}
&__change-header {
display: flex;
align-items: center;
gap: 0.375rem;
margin-bottom: 0.25rem;
}
&__avatar {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.5rem;
font-weight: 700;
color: #fff;
}
&__author {
font-size: 0.6875rem;
font-weight: 600;
color: var(--rt-editor-text);
}
&__type-badge {
font-size: 0.5625rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.0625rem 0.25rem;
border-radius: 2px;
letter-spacing: 0.025em;
}
&__change--insertion &__type-badge {
background: var(--rt-insertion-border);
color: var(--rt-insertion-text);
}
&__change--deletion &__type-badge {
background: var(--rt-deletion-border);
color: var(--rt-deletion-text);
}
&__change-content {
font-size: 0.8125rem;
line-height: 1.4;
margin-bottom: 0.25rem;
}
&__inserted {
color: var(--rt-insertion-text);
text-decoration: underline;
}
&__deleted {
color: var(--rt-deletion-text);
text-decoration: line-through;
}
&__change-actions {
display: flex;
gap: 0.25rem;
}
&__btn {
padding: 0.1875rem 0.5rem;
border: none;
border-radius: var(--rt-toolbar-btn-radius);
font-size: 0.625rem;
font-weight: 500;
cursor: pointer;
transition: all var(--rt-transition);
&--accept, &--accept-sm {
background: rgba(34, 197, 94, 0.2);
color: #166534;
&:hover { background: rgba(34, 197, 94, 0.3); }
}
&--reject, &--reject-sm {
background: rgba(239, 68, 68, 0.2);
color: #991b1b;
&:hover { background: rgba(239, 68, 68, 0.3); }
}
}
&__empty {
padding: 1.5rem;
text-align: center;
font-size: 0.8125rem;
color: var(--rt-status-text);
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-editor-radius);
}
}

View File

@@ -0,0 +1,55 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { ButtonComponent } from '@sda/base-ui';
import { BadgeComponent } from '@sda/base-ui';
import { IconComponent } from '@sda/base-ui';
import type { RtTrackChange } from '../../types/collaboration.types';
import type { RtTrackChangeEvent } from '../../types/event.types';
@Component({
selector: 'rt-track-changes',
standalone: true,
imports: [CommonModule, ButtonComponent, BadgeComponent, IconComponent],
templateUrl: './rt-track-changes.component.html',
styleUrl: './rt-track-changes.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtTrackChangesComponent {
readonly changes = input<RtTrackChange[]>([]);
readonly enabled = input(false);
readonly trackChangeEvent = output<RtTrackChangeEvent>();
readonly showPanel = signal(false);
togglePanel(): void {
this.showPanel.update(v => !v);
}
onAccept(change: RtTrackChange): void {
this.trackChangeEvent.emit({ type: 'accept', change });
}
onReject(change: RtTrackChange): void {
this.trackChangeEvent.emit({ type: 'reject', change });
}
onAcceptAll(): void {
this.trackChangeEvent.emit({ type: 'accept-all' });
}
onRejectAll(): void {
this.trackChangeEvent.emit({ type: 'reject-all' });
}
formatDate(isoString: string): string {
const date = new Date(isoString);
return date.toLocaleString();
}
}

View File

@@ -0,0 +1 @@
export * from './rt-variable-picker.component';

View File

@@ -0,0 +1,43 @@
<div class="rt-variable-picker">
<div class="rt-variable-picker__header">
<ui-input
size="sm"
placeholder="Search variables..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event)"
/>
<ui-button variant="ghost" size="sm" [iconOnly]="true" (click)="onClose()">
<ui-icon name="close" [size]="14" />
</ui-button>
</div>
<div class="rt-variable-picker__list">
@for (group of groupedVariables(); track group[0]) {
<div class="rt-variable-picker__category">
<div class="rt-variable-picker__category-label">{{ group[0] }}</div>
@for (variable of group[1]; track variable.key) {
<button
type="button"
class="rt-variable-picker__item"
[title]="variable.description || variable.key"
(click)="onSelect(variable)"
>
<div class="rt-variable-picker__item-content">
<span class="rt-variable-picker__item-label">{{ variable.label }}</span>
<span class="rt-variable-picker__item-key">{{ variable.key }}</span>
</div>
<ui-badge [variant]="getTypeBadgeVariant(variable.type)" size="sm">
{{ variable.type }}
</ui-badge>
</button>
}
</div>
}
@if (filteredVariables().length === 0) {
<div class="rt-variable-picker__empty">
No variables found
</div>
}
</div>
</div>

View File

@@ -0,0 +1,121 @@
@use '../../styles/tokens';
.rt-variable-picker {
background: var(--rt-editor-bg);
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-editor-radius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
width: 280px;
max-height: 360px;
display: flex;
flex-direction: column;
&__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid var(--rt-editor-border);
}
&__search {
flex: 1;
padding: 0.375rem 0.625rem;
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-toolbar-btn-radius);
background: transparent;
color: var(--rt-editor-text);
font-size: 0.8125rem;
outline: none;
&:focus { border-color: var(--rt-editor-border-focus); }
&::placeholder { color: var(--rt-editor-placeholder); }
}
&__close-btn {
background: none;
border: none;
font-size: 1.125rem;
color: var(--rt-editor-text);
cursor: pointer;
padding: 0.125rem 0.25rem;
line-height: 1;
opacity: 0.6;
&:hover { opacity: 1; }
}
&__list {
overflow-y: auto;
flex: 1;
}
&__category {
padding: 0.25rem 0;
}
&__category-label {
padding: 0.375rem 0.75rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--rt-status-text);
text-transform: uppercase;
letter-spacing: 0.05em;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background var(--rt-transition);
&:hover {
background: var(--rt-toolbar-btn-hover);
}
}
&__item-content {
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
&__item-label {
font-size: 0.8125rem;
color: var(--rt-editor-text);
font-weight: 500;
}
&__item-key {
font-size: 0.6875rem;
color: var(--rt-status-text);
font-family: var(--rt-code-font-family);
}
&__badge {
font-size: 0.625rem;
font-weight: 600;
padding: 0.125rem 0.375rem;
border-radius: var(--rt-var-radius);
text-transform: uppercase;
letter-spacing: 0.025em;
&--text { background: #dbeafe; color: #1d4ed8; }
&--number { background: #dcfce7; color: #166534; }
&--date { background: #fef3c7; color: #92400e; }
&--boolean { background: #f3e8ff; color: #7c3aed; }
&--list { background: #ffe4e6; color: #9f1239; }
}
&__empty {
padding: 1.5rem;
text-align: center;
font-size: 0.8125rem;
color: var(--rt-status-text);
}
}

View File

@@ -0,0 +1,67 @@
import {
Component,
ChangeDetectionStrategy,
input,
output,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { InputComponent } from '@sda/base-ui';
import { BadgeComponent } from '@sda/base-ui';
import { ButtonComponent } from '@sda/base-ui';
import { IconComponent } from '@sda/base-ui';
import type { RtTemplateVariable } from '../../types/template.types';
import { filterVariables } from '../../utils/template.utils';
@Component({
selector: 'rt-variable-picker',
standalone: true,
imports: [CommonModule, FormsModule, InputComponent, BadgeComponent, ButtonComponent, IconComponent],
templateUrl: './rt-variable-picker.component.html',
styleUrl: './rt-variable-picker.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RtVariablePickerComponent {
readonly variables = input<RtTemplateVariable[]>([]);
readonly variableSelect = output<RtTemplateVariable>();
readonly close = output<void>();
readonly searchQuery = signal('');
readonly filteredVariables = computed(() =>
filterVariables(this.variables(), this.searchQuery()),
);
readonly groupedVariables = computed(() => {
const filtered = this.filteredVariables();
const groups = new Map<string, RtTemplateVariable[]>();
for (const v of filtered) {
const list = groups.get(v.category) ?? [];
list.push(v);
groups.set(v.category, list);
}
return Array.from(groups.entries());
});
onSelect(variable: RtTemplateVariable): void {
this.variableSelect.emit(variable);
}
onClose(): void {
this.close.emit();
}
getTypeBadgeVariant(type: string): 'default' | 'primary' | 'success' | 'warning' | 'error' | 'info' {
switch (type) {
case 'text': return 'primary';
case 'number': return 'success';
case 'date': return 'warning';
case 'boolean': return 'info';
case 'list': return 'error';
default: return 'default';
}
}
}

47
src/index.ts Normal file
View File

@@ -0,0 +1,47 @@
/**
* Rich Text Elements UI
* Main library entry point
*
* Angular components for rich text editing with WYSIWYG, markdown, templates,
* and collaboration powered by TipTap and @sda/base-ui
*
* @example
* ```typescript
* import { Component } from '@angular/core';
* import { RtEditorComponent, type RtContentChangeEvent } from '@sda/rich-text-elements-ui';
*
* @Component({
* standalone: true,
* imports: [RtEditorComponent],
* template: `
* <rt-editor
* [content]="content"
* toolbarPreset="standard"
* placeholder="Start writing..."
* (contentChange)="onContentChange($event)"
* />
* `
* })
* export class AppComponent {
* content = '<p>Hello world</p>';
* onContentChange(event: RtContentChangeEvent) {
* console.log(event.html);
* }
* }
* ```
*/
// Types
export * from './types';
// Utils
export * from './utils';
// Providers
export * from './providers';
// Services
export * from './services';
// Components
export * from './components';

1
src/providers/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './rich-text-config.provider';

View File

@@ -0,0 +1,14 @@
import { InjectionToken, makeEnvironmentProviders, type EnvironmentProviders } from '@angular/core';
import type { RtConfig } from '../types/config.types';
import { DEFAULT_RICH_TEXT_CONFIG } from '../types/config.types';
export const RICH_TEXT_CONFIG = new InjectionToken<RtConfig>('RICH_TEXT_CONFIG', {
providedIn: 'root',
factory: () => DEFAULT_RICH_TEXT_CONFIG,
});
export function provideRichTextConfig(config: Partial<RtConfig> = {}): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: RICH_TEXT_CONFIG, useValue: { ...DEFAULT_RICH_TEXT_CONFIG, ...config } },
]);
}

View File

@@ -0,0 +1,144 @@
import { Injectable, signal, computed } from '@angular/core';
import type { RtComment, RtCommentReply, RtSuggestion, RtTrackChange, RtCollabUser } from '../types/collaboration.types';
import { generateId } from '../utils/editor.utils';
@Injectable()
export class CollaborationService {
readonly comments = signal<RtComment[]>([]);
readonly suggestions = signal<RtSuggestion[]>([]);
readonly trackChanges = signal<RtTrackChange[]>([]);
readonly trackChangesEnabled = signal(false);
readonly activeComments = computed(() => this.comments().filter(c => !c.resolved));
readonly resolvedComments = computed(() => this.comments().filter(c => c.resolved));
readonly pendingSuggestions = computed(() => this.suggestions().filter(s => s.status === 'pending'));
/** Add a new comment */
addComment(author: RtCollabUser, content: string, from: number, to: number, quotedText?: string): RtComment {
const comment: RtComment = {
id: generateId('comment'),
author,
content,
quotedText,
from,
to,
resolved: false,
replies: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
this.comments.update(list => [...list, comment]);
return comment;
}
/** Resolve a comment */
resolveComment(commentId: string): void {
this.comments.update(list =>
list.map(c => c.id === commentId ? { ...c, resolved: true, updatedAt: new Date().toISOString() } : c),
);
}
/** Reopen a resolved comment */
reopenComment(commentId: string): void {
this.comments.update(list =>
list.map(c => c.id === commentId ? { ...c, resolved: false, updatedAt: new Date().toISOString() } : c),
);
}
/** Delete a comment */
deleteComment(commentId: string): void {
this.comments.update(list => list.filter(c => c.id !== commentId));
}
/** Add a reply to a comment */
addReply(commentId: string, author: RtCollabUser, content: string): RtCommentReply {
const reply: RtCommentReply = {
id: generateId('reply'),
author,
content,
createdAt: new Date().toISOString(),
};
this.comments.update(list =>
list.map(c => c.id === commentId
? { ...c, replies: [...c.replies, reply], updatedAt: new Date().toISOString() }
: c,
),
);
return reply;
}
/** Set comments (e.g., loading from external source) */
setComments(comments: RtComment[]): void {
this.comments.set(comments);
}
/** Add a suggestion */
addSuggestion(author: RtCollabUser, originalText: string, replacementText: string, from: number, to: number): RtSuggestion {
const suggestion: RtSuggestion = {
id: generateId('suggestion'),
author,
originalText,
replacementText,
from,
to,
status: 'pending',
createdAt: new Date().toISOString(),
};
this.suggestions.update(list => [...list, suggestion]);
return suggestion;
}
/** Accept a suggestion */
acceptSuggestion(suggestionId: string): void {
this.suggestions.update(list =>
list.map(s => s.id === suggestionId ? { ...s, status: 'accepted' as const } : s),
);
}
/** Reject a suggestion */
rejectSuggestion(suggestionId: string): void {
this.suggestions.update(list =>
list.map(s => s.id === suggestionId ? { ...s, status: 'rejected' as const } : s),
);
}
/** Toggle track changes mode */
toggleTrackChanges(): void {
this.trackChangesEnabled.update(v => !v);
}
/** Add a track change entry */
addTrackChange(author: RtCollabUser, type: 'insertion' | 'deletion', content: string, from: number, to: number): RtTrackChange {
const change: RtTrackChange = {
id: generateId('change'),
author,
type,
content,
from,
to,
createdAt: new Date().toISOString(),
};
this.trackChanges.update(list => [...list, change]);
return change;
}
/** Accept a track change */
acceptChange(changeId: string): void {
this.trackChanges.update(list => list.filter(c => c.id !== changeId));
}
/** Reject a track change */
rejectChange(changeId: string): void {
this.trackChanges.update(list => list.filter(c => c.id !== changeId));
}
/** Accept all track changes */
acceptAllChanges(): void {
this.trackChanges.set([]);
}
/** Reject all track changes */
rejectAllChanges(): void {
this.trackChanges.set([]);
}
}

View File

@@ -0,0 +1,93 @@
import { Injectable, signal, computed } from '@angular/core';
import type { Editor } from '@tiptap/core';
import type { RtActiveMarks, RtActiveNodes } from '../types/editor.types';
const EMPTY_MARKS: RtActiveMarks = {
bold: false,
italic: false,
underline: false,
strike: false,
code: false,
subscript: false,
superscript: false,
link: false,
highlight: false,
color: null,
highlightColor: null,
};
const EMPTY_NODES: RtActiveNodes = {
paragraph: false,
heading: null,
bulletList: false,
orderedList: false,
taskList: false,
blockquote: false,
codeBlock: false,
table: false,
image: false,
horizontalRule: false,
textAlign: null,
};
@Injectable()
export class EditorStateService {
readonly focused = signal(false);
readonly wordCount = signal(0);
readonly characterCount = signal(0);
readonly isEmpty = signal(true);
readonly canUndo = signal(false);
readonly canRedo = signal(false);
readonly activeMarks = signal<RtActiveMarks>(EMPTY_MARKS);
readonly activeNodes = signal<RtActiveNodes>(EMPTY_NODES);
readonly selectionFrom = signal(0);
readonly selectionTo = signal(0);
readonly hasSelection = computed(() => this.selectionFrom() !== this.selectionTo());
/** Sync TipTap editor state into signals */
updateFromEditor(editor: Editor): void {
this.isEmpty.set(editor.isEmpty);
this.canUndo.set(editor.can().undo());
this.canRedo.set(editor.can().redo());
const text = editor.getText();
this.wordCount.set(text.trim() ? text.trim().split(/\s+/).length : 0);
this.characterCount.set(text.length);
const { from, to } = editor.state.selection;
this.selectionFrom.set(from);
this.selectionTo.set(to);
this.activeMarks.set({
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
strike: editor.isActive('strike'),
code: editor.isActive('code'),
subscript: editor.isActive('subscript'),
superscript: editor.isActive('superscript'),
link: editor.isActive('link'),
highlight: editor.isActive('highlight'),
color: editor.getAttributes('textStyle')['color'] ?? null,
highlightColor: editor.getAttributes('highlight')['color'] ?? null,
});
this.activeNodes.set({
paragraph: editor.isActive('paragraph'),
heading: editor.isActive('heading') ? (editor.getAttributes('heading')['level'] as number) : null,
bulletList: editor.isActive('bulletList'),
orderedList: editor.isActive('orderedList'),
taskList: editor.isActive('taskList'),
blockquote: editor.isActive('blockquote'),
codeBlock: editor.isActive('codeBlock'),
table: editor.isActive('table'),
image: editor.isActive('image'),
horizontalRule: editor.isActive('horizontalRule'),
textAlign: (editor.getAttributes('paragraph')['textAlign'] as 'left' | 'center' | 'right' | 'justify') ?? null,
});
}
setFocused(focused: boolean): void {
this.focused.set(focused);
}
}

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@angular/core';
import type { Editor } from '@tiptap/core';
@Injectable()
export class HistoryService {
/** Undo the last action */
undo(editor: Editor): void {
editor.chain().focus().undo().run();
}
/** Redo the last undone action */
redo(editor: Editor): void {
editor.chain().focus().redo().run();
}
/** Check if undo is available */
canUndo(editor: Editor): boolean {
return editor.can().undo();
}
/** Check if redo is available */
canRedo(editor: Editor): boolean {
return editor.can().redo();
}
}

6
src/services/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './editor-state.service';
export * from './toolbar.service';
export * from './template.service';
export * from './collaboration.service';
export * from './markdown.service';
export * from './history.service';

View File

@@ -0,0 +1,83 @@
import { Injectable } from '@angular/core';
import { marked } from 'marked';
@Injectable()
export class MarkdownService {
constructor() {
marked.setOptions({
breaks: true,
gfm: true,
});
}
/** Convert markdown to HTML */
toHtml(markdown: string): string {
return marked.parse(markdown, { async: false }) as string;
}
/** Convert HTML to markdown (simplified rule-based conversion) */
toMarkdown(html: string): string {
let md = html;
// Headings
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
md = md.replace(/<h5[^>]*>(.*?)<\/h5>/gi, '##### $1\n\n');
md = md.replace(/<h6[^>]*>(.*?)<\/h6>/gi, '###### $1\n\n');
// Bold / italic / strikethrough / code
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
md = md.replace(/<s[^>]*>(.*?)<\/s>/gi, '~~$1~~');
md = md.replace(/<del[^>]*>(.*?)<\/del>/gi, '~~$1~~');
md = md.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$1`');
// Links and images
md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/gi, '[$2]($1)');
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*\/?>/gi, '![$2]($1)');
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, '![]($1)');
// Lists
md = md.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (_, content: string) => {
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n') + '\n';
});
md = md.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (_, content: string) => {
let i = 1;
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, () => `${i++}. $1\n`) + '\n';
});
// Blockquote
md = md.replace(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi, (_, content: string) => {
return content.trim().split('\n').map((line: string) => `> ${line}`).join('\n') + '\n\n';
});
// Code blocks
md = md.replace(/<pre[^>]*><code[^>]*>([\s\S]*?)<\/code><\/pre>/gi, '```\n$1\n```\n\n');
// Horizontal rule
md = md.replace(/<hr[^>]*\/?>/gi, '---\n\n');
// Paragraphs and line breaks
md = md.replace(/<br[^>]*\/?>/gi, '\n');
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
// Strip remaining HTML tags
md = md.replace(/<[^>]+>/g, '');
// Decode common HTML entities
md = md.replace(/&amp;/g, '&');
md = md.replace(/&lt;/g, '<');
md = md.replace(/&gt;/g, '>');
md = md.replace(/&quot;/g, '"');
md = md.replace(/&#39;/g, "'");
md = md.replace(/&nbsp;/g, ' ');
// Clean up excessive whitespace
md = md.replace(/\n{3,}/g, '\n\n');
return md.trim();
}
}

View File

@@ -0,0 +1,47 @@
import { Injectable, signal, computed } from '@angular/core';
import type { RtTemplateVariable, RtVariableCategory, RtTemplateContext } from '../types/template.types';
import { filterVariables, resolveTemplate, validateTemplateValues } from '../utils/template.utils';
@Injectable()
export class TemplateService {
readonly variables = signal<RtTemplateVariable[]>([]);
readonly categories = computed<RtVariableCategory[]>(() => {
const vars = this.variables();
const categoryMap = new Map<string, RtTemplateVariable[]>();
for (const v of vars) {
const list = categoryMap.get(v.category) ?? [];
list.push(v);
categoryMap.set(v.category, list);
}
return Array.from(categoryMap.entries()).map(([key, variables]) => ({
key,
label: key,
variables,
}));
});
readonly searchQuery = signal('');
readonly filteredVariables = computed(() =>
filterVariables(this.variables(), this.searchQuery()),
);
/** Set the available variables */
setVariables(variables: RtTemplateVariable[]): void {
this.variables.set(variables);
}
/** Update search query for variable filtering */
setSearchQuery(query: string): void {
this.searchQuery.set(query);
}
/** Resolve all variables in a template string */
resolve(content: string, context: RtTemplateContext): string {
return resolveTemplate(content, context);
}
/** Validate all required variables have values */
validate(context: RtTemplateContext): { valid: boolean; missing: string[] } {
return validateTemplateValues(this.variables(), context);
}
}

View File

@@ -0,0 +1,71 @@
import { Injectable, inject } from '@angular/core';
import type { Editor } from '@tiptap/core';
import type { RtToolbarAction, RtToolbarGroup, RtToolbarPreset } from '../types/toolbar.types';
import { RICH_TEXT_CONFIG } from '../providers/rich-text-config.provider';
import { TOOLBAR_PRESETS } from '../utils/toolbar.utils';
@Injectable()
export class ToolbarService {
private config = inject(RICH_TEXT_CONFIG);
/** Get toolbar groups for a preset */
getPresetGroups(preset?: RtToolbarPreset): RtToolbarGroup[] {
return TOOLBAR_PRESETS[preset ?? this.config.toolbarPreset] ?? TOOLBAR_PRESETS['standard'];
}
/** Execute a toolbar action on the editor */
executeAction(editor: Editor, action: RtToolbarAction, value?: string): void {
const chain = editor.chain().focus();
switch (action) {
case 'bold': chain.toggleBold().run(); break;
case 'italic': chain.toggleItalic().run(); break;
case 'underline': chain.toggleUnderline().run(); break;
case 'strikethrough': chain.toggleStrike().run(); break;
case 'code': chain.toggleCode().run(); break;
case 'subscript': chain.toggleSubscript().run(); break;
case 'superscript': chain.toggleSuperscript().run(); break;
case 'heading':
if (value && value !== '0') {
chain.toggleHeading({ level: Number(value) as 1 | 2 | 3 | 4 }).run();
} else {
chain.setParagraph().run();
}
break;
case 'heading-1': chain.toggleHeading({ level: 1 }).run(); break;
case 'heading-2': chain.toggleHeading({ level: 2 }).run(); break;
case 'heading-3': chain.toggleHeading({ level: 3 }).run(); break;
case 'heading-4': chain.toggleHeading({ level: 4 }).run(); break;
case 'bullet-list': chain.toggleBulletList().run(); break;
case 'ordered-list': chain.toggleOrderedList().run(); break;
case 'task-list': chain.toggleTaskList().run(); break;
case 'blockquote': chain.toggleBlockquote().run(); break;
case 'code-block': chain.toggleCodeBlock().run(); break;
case 'horizontal-rule': chain.setHorizontalRule().run(); break;
case 'align-left': chain.setTextAlign('left').run(); break;
case 'align-center': chain.setTextAlign('center').run(); break;
case 'align-right': chain.setTextAlign('right').run(); break;
case 'align-justify': chain.setTextAlign('justify').run(); break;
case 'text-color':
if (value) { chain.setColor(value).run(); }
else { chain.unsetColor().run(); }
break;
case 'highlight-color':
if (value) { chain.toggleHighlight({ color: value }).run(); }
else { chain.unsetHighlight().run(); }
break;
case 'undo': chain.undo().run(); break;
case 'redo': chain.redo().run(); break;
case 'clear-formatting': chain.unsetAllMarks().clearNodes().run(); break;
// link, image, table handled by their dialog components
default: break;
}
}
}

2
src/styles/_index.scss Normal file
View File

@@ -0,0 +1,2 @@
@forward 'tokens';
@forward 'mixins';

224
src/styles/_mixins.scss Normal file
View File

@@ -0,0 +1,224 @@
@mixin rt-container {
display: block;
position: relative;
width: 100%;
font-family: var(--rt-editor-font-family, inherit);
}
@mixin rt-card {
background: var(--rt-editor-bg);
border: 1px solid var(--rt-editor-border);
border-radius: var(--rt-editor-radius);
overflow: hidden;
}
@mixin rt-toolbar-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rt-toolbar-btn-size);
height: var(--rt-toolbar-btn-size);
border: none;
border-radius: var(--rt-toolbar-btn-radius);
background: transparent;
color: var(--rt-editor-text);
cursor: pointer;
transition: all var(--rt-transition);
padding: 0;
&:hover {
background: var(--rt-toolbar-btn-hover);
}
&.is-active {
background: var(--rt-toolbar-btn-active);
color: var(--rt-toolbar-btn-active-text);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
@mixin rt-prose {
color: var(--rt-editor-text);
font-size: var(--rt-editor-font-size);
line-height: var(--rt-editor-line-height);
> * + * {
margin-top: 0.75em;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.3;
font-weight: 600;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
h1 { font-size: 2em; }
h2 { font-size: 1.5em; }
h3 { font-size: 1.25em; }
h4 { font-size: 1.125em; }
p {
margin: 0;
}
a {
color: var(--color-primary-500, #3b82f6);
text-decoration: underline;
cursor: pointer;
}
strong { font-weight: 700; }
em { font-style: italic; }
u { text-decoration: underline; }
s { text-decoration: line-through; }
sub { vertical-align: sub; font-size: 0.75em; }
sup { vertical-align: super; font-size: 0.75em; }
code {
background: var(--rt-inline-code-bg);
color: var(--rt-inline-code-text);
border-radius: var(--rt-inline-code-radius);
padding: 0.125em 0.25em;
font-family: var(--rt-code-font-family);
font-size: 0.9em;
}
pre {
background: var(--rt-code-bg);
border: 1px solid var(--rt-code-border);
border-radius: var(--rt-code-radius);
padding: 1rem;
overflow-x: auto;
code {
background: none;
color: inherit;
padding: 0;
border-radius: 0;
font-size: var(--rt-code-font-size);
}
}
blockquote {
border-left: var(--rt-blockquote-border-width) solid var(--rt-blockquote-border);
background: var(--rt-blockquote-bg);
padding: var(--rt-blockquote-padding);
color: var(--rt-blockquote-text);
margin: 0;
}
ul, ol {
padding-left: 1.5em;
}
ul { list-style-type: disc; }
ol { list-style-type: decimal; }
li {
margin: 0.25em 0;
> p { margin: 0; }
}
ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
li {
display: flex;
align-items: center;
gap: 0.5em;
> label {
flex-shrink: 0;
display: flex;
align-items: center;
input[type="checkbox"] {
cursor: pointer;
margin: 0;
}
}
> div {
flex: 1;
}
}
}
hr {
border: none;
border-top: 2px solid var(--rt-editor-border);
margin: 1.5em 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1em 0;
overflow: auto;
th, td {
border: 1px solid var(--rt-table-border);
padding: var(--rt-table-cell-padding);
text-align: left;
vertical-align: top;
}
th {
background: var(--rt-table-header-bg);
font-weight: 600;
}
.selectedCell {
background: var(--rt-table-selected-bg);
}
}
img {
max-width: 100%;
height: auto;
border-radius: var(--radius-sm, 0.25rem);
&.ProseMirror-selectednode {
outline: 2px solid var(--rt-editor-border-focus);
}
}
// Placeholder
p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: var(--rt-editor-placeholder);
pointer-events: none;
height: 0;
}
// Template variables
span[data-type="variable"] {
background: var(--rt-var-bg);
color: var(--rt-var-text);
border: 1px solid var(--rt-var-border);
border-radius: var(--rt-var-radius);
padding: var(--rt-var-padding);
font-size: 0.875em;
font-weight: 500;
white-space: nowrap;
cursor: default;
}
}
@mixin rt-flex-center {
display: flex;
align-items: center;
}

103
src/styles/_tokens.scss Normal file
View File

@@ -0,0 +1,103 @@
:root {
// Editor
--rt-editor-bg: var(--color-bg-secondary, #ffffff);
--rt-editor-text: var(--color-text-primary, #111827);
--rt-editor-border: var(--color-border-primary, #e5e7eb);
--rt-editor-border-focus: var(--color-primary-500, #3b82f6);
--rt-editor-placeholder: var(--color-text-muted, #9ca3af);
--rt-editor-padding: 1rem;
--rt-editor-radius: var(--radius-md, 0.5rem);
--rt-editor-min-height: 200px;
--rt-editor-font-size: 1rem;
--rt-editor-line-height: 1.75;
// Toolbar
--rt-toolbar-bg: var(--color-bg-secondary, #ffffff);
--rt-toolbar-border: var(--color-border-primary, #e5e7eb);
--rt-toolbar-btn-size: 2rem;
--rt-toolbar-btn-radius: var(--radius-sm, 0.25rem);
--rt-toolbar-btn-hover: var(--color-bg-hover, #f3f4f6);
--rt-toolbar-btn-active: var(--color-primary-50, #eff6ff);
--rt-toolbar-btn-active-text: var(--color-primary-500, #3b82f6);
--rt-toolbar-separator: var(--color-border-primary, #e5e7eb);
--rt-toolbar-gap: 0.25rem;
--rt-toolbar-padding: 0.5rem;
// Code blocks
--rt-code-bg: var(--color-bg-primary, #f9fafb);
--rt-code-text: var(--color-text-primary, #111827);
--rt-code-border: var(--color-border-primary, #e5e7eb);
--rt-code-radius: var(--radius-md, 0.5rem);
--rt-code-font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace;
--rt-code-font-size: 0.875rem;
--rt-code-line-number: var(--color-text-muted, #9ca3af);
// Inline code
--rt-inline-code-bg: var(--color-bg-primary, #f3f4f6);
--rt-inline-code-text: var(--color-error-500, #ef4444);
--rt-inline-code-radius: var(--radius-sm, 0.25rem);
// Blockquote
--rt-blockquote-border: var(--color-primary-500, #3b82f6);
--rt-blockquote-bg: var(--color-primary-50, #eff6ff);
--rt-blockquote-text: var(--color-text-secondary, #6b7280);
--rt-blockquote-border-width: 3px;
--rt-blockquote-padding: 0.75rem 1rem;
// Table
--rt-table-border: var(--color-border-primary, #e5e7eb);
--rt-table-header-bg: var(--color-bg-primary, #f9fafb);
--rt-table-cell-padding: 0.5rem 0.75rem;
--rt-table-selected-bg: var(--color-primary-50, #eff6ff);
// Template variables
--rt-var-bg: var(--color-primary-50, #eff6ff);
--rt-var-text: var(--color-primary-700, #1d4ed8);
--rt-var-border: var(--color-primary-200, #bfdbfe);
--rt-var-radius: var(--radius-sm, 0.25rem);
--rt-var-padding: 0.125rem 0.375rem;
// Comments
--rt-comment-bg: var(--color-bg-secondary, #ffffff);
--rt-comment-border: var(--color-border-primary, #e5e7eb);
--rt-comment-highlight: rgba(255, 212, 0, 0.3);
--rt-comment-active-highlight: rgba(255, 212, 0, 0.5);
--rt-comment-resolved-bg: var(--color-bg-primary, #f9fafb);
// Track changes
--rt-insertion-bg: rgba(34, 197, 94, 0.15);
--rt-insertion-text: #166534;
--rt-insertion-border: rgba(34, 197, 94, 0.4);
--rt-deletion-bg: rgba(239, 68, 68, 0.15);
--rt-deletion-text: #991b1b;
--rt-deletion-border: rgba(239, 68, 68, 0.4);
// Bubble menu
--rt-bubble-bg: var(--color-bg-secondary, #ffffff);
--rt-bubble-border: var(--color-border-primary, #e5e7eb);
--rt-bubble-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
--rt-bubble-radius: var(--radius-md, 0.5rem);
// Status bar
--rt-status-bg: var(--color-bg-primary, #f9fafb);
--rt-status-text: var(--color-text-muted, #9ca3af);
--rt-status-font-size: 0.75rem;
// Transition
--rt-transition: 150ms ease-in-out;
}
@media (prefers-color-scheme: dark) {
:root {
--rt-editor-bg: var(--color-bg-secondary, #1f2937);
--rt-editor-text: var(--color-text-primary, #f9fafb);
--rt-code-bg: var(--color-bg-primary, #111827);
--rt-inline-code-bg: var(--color-bg-primary, #374151);
--rt-inline-code-text: #fca5a5;
--rt-comment-highlight: rgba(255, 212, 0, 0.2);
--rt-insertion-bg: rgba(34, 197, 94, 0.1);
--rt-insertion-text: #86efac;
--rt-deletion-bg: rgba(239, 68, 68, 0.1);
--rt-deletion-text: #fca5a5;
}
}

View File

@@ -0,0 +1,85 @@
/** A comment on the document */
export interface RtComment {
/** Unique comment ID */
id: string;
/** Author of the comment */
author: RtCollabUser;
/** Comment text content */
content: string;
/** Quoted text from the document */
quotedText?: string;
/** Position range in the document */
from: number;
/** End position range in the document */
to: number;
/** Whether the comment is resolved */
resolved: boolean;
/** Replies to this comment */
replies: RtCommentReply[];
/** Creation timestamp */
createdAt: string;
/** Last update timestamp */
updatedAt: string;
}
/** A reply to a comment */
export interface RtCommentReply {
/** Unique reply ID */
id: string;
/** Author of the reply */
author: RtCollabUser;
/** Reply text content */
content: string;
/** Creation timestamp */
createdAt: string;
}
/** A suggestion (inline edit proposal) */
export interface RtSuggestion {
/** Unique suggestion ID */
id: string;
/** Author of the suggestion */
author: RtCollabUser;
/** Original text */
originalText: string;
/** Suggested replacement */
replacementText: string;
/** Position range in the document */
from: number;
/** End position range in the document */
to: number;
/** Status */
status: 'pending' | 'accepted' | 'rejected';
/** Creation timestamp */
createdAt: string;
}
/** Track change entry */
export interface RtTrackChange {
/** Unique change ID */
id: string;
/** Author of the change */
author: RtCollabUser;
/** Type of change */
type: 'insertion' | 'deletion';
/** Changed text content */
content: string;
/** Position in the document */
from: number;
/** End position in the document */
to: number;
/** Creation timestamp */
createdAt: string;
}
/** A collaborative user */
export interface RtCollabUser {
/** Unique user ID */
id: string;
/** Display name */
name: string;
/** User color (for cursors, highlights) */
color: string;
/** Avatar URL */
avatar?: string;
}

48
src/types/config.types.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { RtToolbarPreset } from './toolbar.types';
/** Rich text editor configuration */
export interface RtConfig {
/** Default toolbar preset */
toolbarPreset: RtToolbarPreset;
/** Whether to show the toolbar by default */
showToolbar: boolean;
/** Whether to show the bubble menu on selection */
showBubbleMenu: boolean;
/** Whether to show word count */
showWordCount: boolean;
/** Whether to show character count */
showCharacterCount: boolean;
/** Default placeholder text */
placeholder: string;
/** Maximum content length (0 = unlimited) */
maxLength: number;
/** Whether the editor is editable by default */
editable: boolean;
/** Whether to enable spell check */
spellcheck: boolean;
/** Code block languages available */
codeLanguages: string[];
/** Whether to auto-link URLs */
autoLink: boolean;
/** Image upload handler (if provided, enables image upload) */
imageUploadHandler?: (file: File) => Promise<string>;
}
/** Default configuration values */
export const DEFAULT_RICH_TEXT_CONFIG: RtConfig = {
toolbarPreset: 'standard',
showToolbar: true,
showBubbleMenu: true,
showWordCount: false,
showCharacterCount: false,
placeholder: 'Start writing...',
maxLength: 0,
editable: true,
spellcheck: true,
codeLanguages: [
'javascript', 'typescript', 'html', 'css', 'scss',
'json', 'python', 'java', 'go', 'rust',
'sql', 'bash', 'yaml', 'xml', 'markdown',
],
autoLink: true,
};

61
src/types/editor.types.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { JSONContent } from '@tiptap/core';
/** Content format for the editor */
export type RtContent = string | JSONContent;
/** Editor mode */
export type RtEditorMode = 'wysiwyg' | 'markdown' | 'template';
/** Currently active marks on the selection */
export interface RtActiveMarks {
bold: boolean;
italic: boolean;
underline: boolean;
strike: boolean;
code: boolean;
subscript: boolean;
superscript: boolean;
link: boolean;
highlight: boolean;
color: string | null;
highlightColor: string | null;
}
/** Currently active nodes at the cursor position */
export interface RtActiveNodes {
paragraph: boolean;
heading: number | null;
bulletList: boolean;
orderedList: boolean;
taskList: boolean;
blockquote: boolean;
codeBlock: boolean;
table: boolean;
image: boolean;
horizontalRule: boolean;
textAlign: 'left' | 'center' | 'right' | 'justify' | null;
}
/** Image data for insertion */
export interface RtImageData {
src: string;
alt?: string;
title?: string;
width?: number;
height?: number;
}
/** Link data for insertion */
export interface RtLinkData {
href: string;
target?: '_blank' | '_self';
text?: string;
}
/** Mention item */
export interface RtMentionItem {
id: string;
label: string;
avatar?: string;
email?: string;
}

75
src/types/event.types.ts Normal file
View File

@@ -0,0 +1,75 @@
import type { RtActiveMarks, RtActiveNodes, RtContent } from './editor.types';
import type { RtComment, RtCommentReply, RtSuggestion, RtTrackChange } from './collaboration.types';
/** Emitted when editor content changes */
export interface RtContentChangeEvent {
/** HTML content */
html: string;
/** JSON content */
json: RtContent;
/** Plain text content */
text: string;
/** Whether the content is empty */
isEmpty: boolean;
}
/** Emitted when selection changes */
export interface RtSelectionChangeEvent {
/** Start position */
from: number;
/** End position */
to: number;
/** Whether there is an active selection (not just cursor) */
hasSelection: boolean;
/** Selected text */
selectedText: string;
/** Active marks at selection */
activeMarks: RtActiveMarks;
/** Active nodes at selection */
activeNodes: RtActiveNodes;
}
/** Emitted when focus state changes */
export interface RtFocusChangeEvent {
/** Whether the editor is focused */
focused: boolean;
}
/** Emitted when word/character count changes */
export interface RtCountChangeEvent {
/** Word count */
words: number;
/** Character count */
characters: number;
}
/** Emitted for comment events */
export interface RtCommentEvent {
type: 'add' | 'resolve' | 'reopen' | 'delete' | 'reply';
comment: RtComment;
reply?: RtCommentReply;
}
/** Emitted for suggestion events */
export interface RtSuggestionEvent {
type: 'add' | 'accept' | 'reject';
suggestion: RtSuggestion;
}
/** Emitted for track change events */
export interface RtTrackChangeEvent {
type: 'accept' | 'reject' | 'accept-all' | 'reject-all';
change?: RtTrackChange;
}
/** Emitted when editor state changes */
export interface RtStateChangeEvent {
/** Whether undo is available */
canUndo: boolean;
/** Whether redo is available */
canRedo: boolean;
/** Active marks */
activeMarks: RtActiveMarks;
/** Active nodes */
activeNodes: RtActiveNodes;
}

6
src/types/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './editor.types';
export * from './toolbar.types';
export * from './template.types';
export * from './collaboration.types';
export * from './config.types';
export * from './event.types';

View File

@@ -0,0 +1,55 @@
/** Variable type for template insertion */
export type RtVariableType = 'text' | 'number' | 'date' | 'boolean' | 'list' | 'rich-text';
/** A template variable definition */
export interface RtTemplateVariable {
/** Unique variable key (e.g., 'user.name') */
key: string;
/** Display label */
label: string;
/** Variable type */
type: RtVariableType;
/** Category for grouping */
category: string;
/** Default value */
defaultValue?: string;
/** Description shown in picker */
description?: string;
/** Whether this variable is required */
required?: boolean;
}
/** Category grouping for variables */
export interface RtVariableCategory {
/** Category key */
key: string;
/** Display label */
label: string;
/** Icon name */
icon?: string;
/** Variables in this category */
variables: RtTemplateVariable[];
}
/** A saved template */
export interface RtTemplate {
/** Unique template ID */
id: string;
/** Template name */
name: string;
/** Template description */
description?: string;
/** HTML content with variable placeholders */
content: string;
/** Variables used in this template */
variables: RtTemplateVariable[];
/** Creation timestamp */
createdAt: string;
/** Last update timestamp */
updatedAt: string;
}
/** Context for resolving template variables */
export interface RtTemplateContext {
[key: string]: string | number | boolean | string[] | undefined;
}

View File

@@ -0,0 +1,44 @@
/** Individual toolbar item */
export interface RtToolbarItem {
/** Unique action key */
action: RtToolbarAction;
/** Icon name (for base-ui IconComponent) */
icon?: string;
/** Tooltip label */
label: string;
/** Whether this is a dropdown (e.g., heading level, color) */
type?: 'button' | 'dropdown' | 'select' | 'separator';
/** Dropdown options for select type */
options?: RtToolbarSelectOption[];
}
/** Option for select-type toolbar items */
export interface RtToolbarSelectOption {
value: string;
label: string;
}
/** A group of toolbar items separated visually */
export interface RtToolbarGroup {
/** Group label (for accessibility) */
label: string;
/** Items in this group */
items: RtToolbarItem[];
}
/** Toolbar action identifiers */
export type RtToolbarAction =
| 'bold' | 'italic' | 'underline' | 'strikethrough'
| 'code' | 'subscript' | 'superscript'
| 'heading' | 'heading-1' | 'heading-2' | 'heading-3' | 'heading-4'
| 'bullet-list' | 'ordered-list' | 'task-list'
| 'blockquote' | 'code-block' | 'horizontal-rule'
| 'align-left' | 'align-center' | 'align-right' | 'align-justify'
| 'link' | 'image' | 'table'
| 'text-color' | 'highlight-color'
| 'undo' | 'redo'
| 'clear-formatting'
| 'separator';
/** Preset toolbar configurations */
export type RtToolbarPreset = 'minimal' | 'standard' | 'full' | 'markdown';

36
src/utils/editor.utils.ts Normal file
View File

@@ -0,0 +1,36 @@
import type { Editor } from '@tiptap/core';
/** Get HTML content from editor */
export function getEditorHtml(editor: Editor): string {
return editor.getHTML();
}
/** Get plain text content from editor */
export function getEditorText(editor: Editor): string {
return editor.getText();
}
/** Count words in text */
export function countWords(text: string): number {
if (!text || !text.trim()) return 0;
return text.trim().split(/\s+/).length;
}
/** Count characters in text (excluding whitespace optionally) */
export function countCharacters(text: string, excludeWhitespace = false): number {
if (!text) return 0;
if (excludeWhitespace) {
return text.replace(/\s/g, '').length;
}
return text.length;
}
/** Generate a unique ID */
export function generateId(prefix = 'rt'): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/** Check if editor content is effectively empty */
export function isEditorEmpty(editor: Editor): boolean {
return editor.isEmpty;
}

50
src/utils/html.utils.ts Normal file
View File

@@ -0,0 +1,50 @@
/** Allowed HTML tags for sanitization */
const ALLOWED_TAGS = new Set([
'p', 'br', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'b', 'em', 'i', 'u', 's', 'del', 'sub', 'sup',
'a', 'img',
'ul', 'ol', 'li',
'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'span', 'div',
'input', 'label',
]);
/** Allowed attributes per tag */
const ALLOWED_ATTRS: Record<string, Set<string>> = {
a: new Set(['href', 'target', 'rel', 'title']),
img: new Set(['src', 'alt', 'title', 'width', 'height']),
td: new Set(['colspan', 'rowspan']),
th: new Set(['colspan', 'rowspan']),
span: new Set(['style', 'data-type', 'data-id', 'data-label', 'class']),
div: new Set(['class', 'data-type']),
input: new Set(['type', 'checked', 'disabled']),
pre: new Set(['class']),
code: new Set(['class']),
li: new Set(['data-type', 'data-checked']),
ul: new Set(['data-type']),
p: new Set(['style']),
h1: new Set(['style']),
h2: new Set(['style']),
h3: new Set(['style']),
h4: new Set(['style']),
};
/** Basic HTML sanitizer — strips disallowed tags and attributes */
export function sanitizeHtml(html: string): string {
// Remove script tags and their content
let sanitized = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
// Remove event handler attributes
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
// Remove javascript: URLs
sanitized = sanitized.replace(/href\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '');
sanitized = sanitized.replace(/src\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi, '');
// Remove data: URLs in src (except images)
sanitized = sanitized.replace(/src\s*=\s*(?:"data:(?!image\/)[^"]*"|'data:(?!image\/)[^']*')/gi, '');
return sanitized;
}

43
src/utils/icons.utils.ts Normal file
View File

@@ -0,0 +1,43 @@
import type { IconRegistry } from '@sda/base-ui';
/** SVG paths for rich-text icons missing from base-ui (Lucide-style, 24x24 stroke) */
const RT_ICONS: Record<string, string> = {
'bold': '<path d="M6 4h8a4 4 0 0 1 0 8H6z"/><path d="M6 12h9a4 4 0 0 1 0 8H6z"/>',
'italic': '<line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/>',
'underline': '<path d="M6 4v6a6 6 0 0 0 12 0V4"/><line x1="4" y1="20" x2="20" y2="20"/>',
'strikethrough': '<path d="M16 4H9a3 3 0 0 0 0 6h6a3 3 0 0 1 0 6H8"/><line x1="4" y1="12" x2="20" y2="12"/>',
'code': '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>',
'subscript': '<path d="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07"/>',
'superscript': '<path d="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 9h-4c0-1.5.44-2 1.5-2.5S20 5.33 20 4c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07"/>',
'heading': '<path d="M6 12h12"/><path d="M6 20V4"/><path d="M18 20V4"/>',
'list-ordered': '<line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/>',
'list-checks': '<path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/>',
'quote': '<line x1="6" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="21" y2="12"/><line x1="6" y1="18" x2="21" y2="18"/><line x1="3" y1="3" x2="3" y2="21"/>',
'file-code': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'table': '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
'highlighter': '<path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/>',
'eraser': '<path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/>',
'align-left': '<line x1="21" y1="6" x2="3" y2="6"/><line x1="15" y1="12" x2="3" y2="12"/><line x1="17" y1="18" x2="3" y2="18"/>',
'align-center': '<line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="12" x2="7" y2="12"/><line x1="19" y1="18" x2="5" y2="18"/>',
'align-right': '<line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="12" x2="9" y2="12"/><line x1="21" y1="18" x2="7" y2="18"/>',
'align-justify': '<line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="12" x2="3" y2="12"/><line x1="21" y1="18" x2="3" y2="18"/>',
'undo': '<path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"/>',
'redo': '<path d="M21 7v6h-6"/><path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3L21 13"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'image': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
'minus': '<path d="M5 12h14"/>',
'palette': '<circle cx="13.5" cy="6.5" r="0.5" fill="currentColor"/><circle cx="17.5" cy="10.5" r="0.5" fill="currentColor"/><circle cx="8.5" cy="7.5" r="0.5" fill="currentColor"/><circle cx="6.5" cy="12" r="0.5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/>',
'edit': '<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>',
'columns': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="12" y1="3" x2="12" y2="21"/>',
'eye': '<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/>',
};
/** Register all rich-text editor icons into the base-ui IconRegistry (idempotent). */
export function registerRtIcons(registry: IconRegistry): void {
for (const [name, svg] of Object.entries(RT_ICONS)) {
if (!registry.get(name)) {
registry.register(name, svg);
}
}
}

6
src/utils/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export * from './editor.utils';
export * from './toolbar.utils';
export * from './markdown.utils';
export * from './template.utils';
export * from './html.utils';
export * from './icons.utils';

View File

@@ -0,0 +1,69 @@
/** Wrap selected text with a markdown syntax marker */
export function wrapSelection(
text: string,
selectionStart: number,
selectionEnd: number,
before: string,
after: string,
): { text: string; selectionStart: number; selectionEnd: number } {
const selectedText = text.slice(selectionStart, selectionEnd);
const newText = text.slice(0, selectionStart) + before + selectedText + after + text.slice(selectionEnd);
return {
text: newText,
selectionStart: selectionStart + before.length,
selectionEnd: selectionEnd + before.length,
};
}
/** Insert a prefix at the beginning of the current line */
export function insertLinePrefix(
text: string,
cursorPos: number,
prefix: string,
): { text: string; cursorPos: number } {
const lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
const newText = text.slice(0, lineStart) + prefix + text.slice(lineStart);
return {
text: newText,
cursorPos: cursorPos + prefix.length,
};
}
/** Insert a block element (code block, etc.) */
export function insertBlock(
text: string,
cursorPos: number,
blockStart: string,
blockEnd: string,
placeholder = '',
): { text: string; selectionStart: number; selectionEnd: number } {
const before = cursorPos > 0 && text[cursorPos - 1] !== '\n' ? '\n' : '';
const after = cursorPos < text.length && text[cursorPos] !== '\n' ? '\n' : '';
const insert = before + blockStart + '\n' + placeholder + '\n' + blockEnd + after;
const newText = text.slice(0, cursorPos) + insert + text.slice(cursorPos);
const placeholderStart = cursorPos + before.length + blockStart.length + 1;
return {
text: newText,
selectionStart: placeholderStart,
selectionEnd: placeholderStart + placeholder.length,
};
}
/** Toggle a line prefix (heading, list, etc.) */
export function toggleLinePrefix(
text: string,
cursorPos: number,
prefix: string,
): { text: string; cursorPos: number } {
const lineStart = text.lastIndexOf('\n', cursorPos - 1) + 1;
const lineEnd = text.indexOf('\n', cursorPos);
const line = text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd);
if (line.startsWith(prefix)) {
const newText = text.slice(0, lineStart) + line.slice(prefix.length) + text.slice(lineEnd === -1 ? text.length : lineEnd);
return { text: newText, cursorPos: cursorPos - prefix.length };
}
const newText = text.slice(0, lineStart) + prefix + text.slice(lineStart);
return { text: newText, cursorPos: cursorPos + prefix.length };
}

View File

@@ -0,0 +1,57 @@
import type { RtTemplateContext, RtTemplateVariable } from '../types/template.types';
/** Variable pattern: {{variable.key}} */
const VARIABLE_PATTERN = /\{\{(\w+(?:\.\w+)*)\}\}/g;
/** Extract all variable keys from template content */
export function extractVariableKeys(content: string): string[] {
const keys = new Set<string>();
let match: RegExpExecArray | null;
while ((match = VARIABLE_PATTERN.exec(content)) !== null) {
keys.add(match[1]);
}
return Array.from(keys);
}
/** Resolve template content by replacing variables with values */
export function resolveTemplate(content: string, context: RtTemplateContext): string {
return content.replace(VARIABLE_PATTERN, (full, key: string) => {
const value = context[key];
if (value === undefined || value === null) return full;
if (Array.isArray(value)) return value.join(', ');
return String(value);
});
}
/** Validate that all required variables have values */
export function validateTemplateValues(
variables: RtTemplateVariable[],
context: RtTemplateContext,
): { valid: boolean; missing: string[] } {
const missing: string[] = [];
for (const v of variables) {
if (v.required && (context[v.key] === undefined || context[v.key] === '')) {
missing.push(v.key);
}
}
return { valid: missing.length === 0, missing };
}
/** Build a variable placeholder string */
export function buildVariablePlaceholder(key: string): string {
return `{{${key}}}`;
}
/** Filter variables by search query */
export function filterVariables(
variables: RtTemplateVariable[],
query: string,
): RtTemplateVariable[] {
if (!query.trim()) return variables;
const q = query.toLowerCase();
return variables.filter(v =>
v.label.toLowerCase().includes(q) ||
v.key.toLowerCase().includes(q) ||
(v.description && v.description.toLowerCase().includes(q)),
);
}

206
src/utils/toolbar.utils.ts Normal file
View File

@@ -0,0 +1,206 @@
import type { RtToolbarGroup, RtToolbarPreset } from '../types/toolbar.types';
/** Toolbar preset configurations */
export const TOOLBAR_PRESETS: Record<RtToolbarPreset, RtToolbarGroup[]> = {
minimal: [
{
label: 'Text formatting',
items: [
{ action: 'bold', icon: 'bold', label: 'Bold', type: 'button' },
{ action: 'italic', icon: 'italic', label: 'Italic', type: 'button' },
{ action: 'underline', icon: 'underline', label: 'Underline', type: 'button' },
],
},
{
label: 'Lists',
items: [
{ action: 'bullet-list', icon: 'list', label: 'Bullet list', type: 'button' },
{ action: 'ordered-list', icon: 'list-ordered', label: 'Ordered list', type: 'button' },
],
},
{
label: 'Insert',
items: [
{ action: 'link', icon: 'link', label: 'Insert link', type: 'button' },
],
},
],
standard: [
{
label: 'History',
items: [
{ action: 'undo', icon: 'undo', label: 'Undo', type: 'button' },
{ action: 'redo', icon: 'redo', label: 'Redo', type: 'button' },
],
},
{
label: 'Headings',
items: [
{
action: 'heading', icon: 'heading', label: 'Heading level', type: 'select',
options: [
{ value: '0', label: 'Normal text' },
{ value: '1', label: 'Heading 1' },
{ value: '2', label: 'Heading 2' },
{ value: '3', label: 'Heading 3' },
],
},
],
},
{
label: 'Text formatting',
items: [
{ action: 'bold', icon: 'bold', label: 'Bold', type: 'button' },
{ action: 'italic', icon: 'italic', label: 'Italic', type: 'button' },
{ action: 'underline', icon: 'underline', label: 'Underline', type: 'button' },
{ action: 'strikethrough', icon: 'strikethrough', label: 'Strikethrough', type: 'button' },
{ action: 'code', icon: 'code', label: 'Inline code', type: 'button' },
],
},
{
label: 'Lists',
items: [
{ action: 'bullet-list', icon: 'list', label: 'Bullet list', type: 'button' },
{ action: 'ordered-list', icon: 'list-ordered', label: 'Ordered list', type: 'button' },
{ action: 'task-list', icon: 'list-checks', label: 'Task list', type: 'button' },
],
},
{
label: 'Block formatting',
items: [
{ action: 'blockquote', icon: 'quote', label: 'Blockquote', type: 'button' },
{ action: 'code-block', icon: 'file-code', label: 'Code block', type: 'button' },
{ action: 'horizontal-rule', icon: 'minus', label: 'Horizontal rule', type: 'button' },
],
},
{
label: 'Insert',
items: [
{ action: 'link', icon: 'link', label: 'Insert link', type: 'button' },
{ action: 'image', icon: 'image', label: 'Insert image', type: 'button' },
],
},
],
full: [
{
label: 'History',
items: [
{ action: 'undo', icon: 'undo', label: 'Undo', type: 'button' },
{ action: 'redo', icon: 'redo', label: 'Redo', type: 'button' },
],
},
{
label: 'Headings',
items: [
{
action: 'heading', icon: 'heading', label: 'Heading level', type: 'select',
options: [
{ value: '0', label: 'Normal text' },
{ value: '1', label: 'Heading 1' },
{ value: '2', label: 'Heading 2' },
{ value: '3', label: 'Heading 3' },
{ value: '4', label: 'Heading 4' },
],
},
],
},
{
label: 'Text formatting',
items: [
{ action: 'bold', icon: 'bold', label: 'Bold', type: 'button' },
{ action: 'italic', icon: 'italic', label: 'Italic', type: 'button' },
{ action: 'underline', icon: 'underline', label: 'Underline', type: 'button' },
{ action: 'strikethrough', icon: 'strikethrough', label: 'Strikethrough', type: 'button' },
{ action: 'code', icon: 'code', label: 'Inline code', type: 'button' },
{ action: 'subscript', icon: 'subscript', label: 'Subscript', type: 'button' },
{ action: 'superscript', icon: 'superscript', label: 'Superscript', type: 'button' },
],
},
{
label: 'Colors',
items: [
{ action: 'text-color', icon: 'palette', label: 'Text color', type: 'button' },
{ action: 'highlight-color', icon: 'highlighter', label: 'Highlight', type: 'button' },
],
},
{
label: 'Alignment',
items: [
{ action: 'align-left', icon: 'align-left', label: 'Align left', type: 'button' },
{ action: 'align-center', icon: 'align-center', label: 'Align center', type: 'button' },
{ action: 'align-right', icon: 'align-right', label: 'Align right', type: 'button' },
{ action: 'align-justify', icon: 'align-justify', label: 'Justify', type: 'button' },
],
},
{
label: 'Lists',
items: [
{ action: 'bullet-list', icon: 'list', label: 'Bullet list', type: 'button' },
{ action: 'ordered-list', icon: 'list-ordered', label: 'Ordered list', type: 'button' },
{ action: 'task-list', icon: 'list-checks', label: 'Task list', type: 'button' },
],
},
{
label: 'Block formatting',
items: [
{ action: 'blockquote', icon: 'quote', label: 'Blockquote', type: 'button' },
{ action: 'code-block', icon: 'file-code', label: 'Code block', type: 'button' },
{ action: 'horizontal-rule', icon: 'minus', label: 'Horizontal rule', type: 'button' },
],
},
{
label: 'Insert',
items: [
{ action: 'link', icon: 'link', label: 'Insert link', type: 'button' },
{ action: 'image', icon: 'image', label: 'Insert image', type: 'button' },
{ action: 'table', icon: 'table', label: 'Insert table', type: 'button' },
],
},
{
label: 'Clear',
items: [
{ action: 'clear-formatting', icon: 'eraser', label: 'Clear formatting', type: 'button' },
],
},
],
markdown: [
{
label: 'Text formatting',
items: [
{ action: 'bold', icon: 'bold', label: 'Bold (**text**)', type: 'button' },
{ action: 'italic', icon: 'italic', label: 'Italic (*text*)', type: 'button' },
{ action: 'strikethrough', icon: 'strikethrough', label: 'Strikethrough (~~text~~)', type: 'button' },
{ action: 'code', icon: 'code', label: 'Inline code (`code`)', type: 'button' },
],
},
{
label: 'Headings',
items: [
{ action: 'heading-1', icon: 'heading', label: 'Heading 1 (#)', type: 'button' },
{ action: 'heading-2', icon: 'heading', label: 'Heading 2 (##)', type: 'button' },
{ action: 'heading-3', icon: 'heading', label: 'Heading 3 (###)', type: 'button' },
],
},
{
label: 'Lists & blocks',
items: [
{ action: 'bullet-list', icon: 'list', label: 'Bullet list (- )', type: 'button' },
{ action: 'ordered-list', icon: 'list-ordered', label: 'Ordered list (1. )', type: 'button' },
{ action: 'task-list', icon: 'list-checks', label: 'Task list (- [ ] )', type: 'button' },
{ action: 'blockquote', icon: 'quote', label: 'Blockquote (> )', type: 'button' },
{ action: 'code-block', icon: 'file-code', label: 'Code block (```)', type: 'button' },
],
},
{
label: 'Insert',
items: [
{ action: 'link', icon: 'link', label: 'Link [text](url)', type: 'button' },
{ action: 'image', icon: 'image', label: 'Image ![alt](url)', type: 'button' },
{ action: 'horizontal-rule', icon: 'minus', label: 'Horizontal rule (---)', type: 'button' },
],
},
],
};

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}