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:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal 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
12
build-for-dev.sh
Executable 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
40
ng-package.json
Normal 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
4941
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
75
package.json
Normal file
75
package.json
Normal 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
14
src/components/index.ts
Normal 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';
|
||||||
1
src/components/rt-bubble-menu/index.ts
Normal file
1
src/components/rt-bubble-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-bubble-menu.component';
|
||||||
15
src/components/rt-bubble-menu/rt-bubble-menu.component.html
Normal file
15
src/components/rt-bubble-menu/rt-bubble-menu.component.html
Normal 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>
|
||||||
18
src/components/rt-bubble-menu/rt-bubble-menu.component.scss
Normal file
18
src/components/rt-bubble-menu/rt-bubble-menu.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/components/rt-bubble-menu/rt-bubble-menu.component.ts
Normal file
87
src/components/rt-bubble-menu/rt-bubble-menu.component.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-code-block/index.ts
Normal file
1
src/components/rt-code-block/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-code-block.component';
|
||||||
14
src/components/rt-code-block/rt-code-block.component.html
Normal file
14
src/components/rt-code-block/rt-code-block.component.html
Normal 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>
|
||||||
52
src/components/rt-code-block/rt-code-block.component.scss
Normal file
52
src/components/rt-code-block/rt-code-block.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/components/rt-code-block/rt-code-block.component.ts
Normal file
37
src/components/rt-code-block/rt-code-block.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-color-picker/index.ts
Normal file
1
src/components/rt-color-picker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-color-picker.component';
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/components/rt-color-picker/rt-color-picker.component.ts
Normal file
54
src/components/rt-color-picker/rt-color-picker.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-comment-sidebar/index.ts
Normal file
1
src/components/rt-comment-sidebar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-comment-sidebar.component';
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-editor/index.ts
Normal file
1
src/components/rt-editor/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-editor.component';
|
||||||
37
src/components/rt-editor/rt-editor.component.html
Normal file
37
src/components/rt-editor/rt-editor.component.html
Normal 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>
|
||||||
41
src/components/rt-editor/rt-editor.component.scss
Normal file
41
src/components/rt-editor/rt-editor.component.scss
Normal 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;
|
||||||
|
}
|
||||||
296
src/components/rt-editor/rt-editor.component.ts
Normal file
296
src/components/rt-editor/rt-editor.component.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-image-dialog/index.ts
Normal file
1
src/components/rt-image-dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-image-dialog.component';
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
57
src/components/rt-image-dialog/rt-image-dialog.component.ts
Normal file
57
src/components/rt-image-dialog/rt-image-dialog.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-link-dialog/index.ts
Normal file
1
src/components/rt-link-dialog/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-link-dialog.component';
|
||||||
31
src/components/rt-link-dialog/rt-link-dialog.component.html
Normal file
31
src/components/rt-link-dialog/rt-link-dialog.component.html
Normal 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>
|
||||||
20
src/components/rt-link-dialog/rt-link-dialog.component.scss
Normal file
20
src/components/rt-link-dialog/rt-link-dialog.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/components/rt-link-dialog/rt-link-dialog.component.ts
Normal file
61
src/components/rt-link-dialog/rt-link-dialog.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-markdown-editor/index.ts
Normal file
1
src/components/rt-markdown-editor/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-markdown-editor.component';
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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, '');
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-mention/index.ts
Normal file
1
src/components/rt-mention/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-mention.component';
|
||||||
29
src/components/rt-mention/rt-mention.component.html
Normal file
29
src/components/rt-mention/rt-mention.component.html
Normal 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>
|
||||||
82
src/components/rt-mention/rt-mention.component.scss
Normal file
82
src/components/rt-mention/rt-mention.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/components/rt-mention/rt-mention.component.ts
Normal file
60
src/components/rt-mention/rt-mention.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-table-inserter/index.ts
Normal file
1
src/components/rt-table-inserter/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-table-inserter.component';
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-template-editor/index.ts
Normal file
1
src/components/rt-template-editor/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-template-editor.component';
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> `
|
||||||
|
).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-toolbar/index.ts
Normal file
1
src/components/rt-toolbar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-toolbar.component';
|
||||||
34
src/components/rt-toolbar/rt-toolbar.component.html
Normal file
34
src/components/rt-toolbar/rt-toolbar.component.html
Normal 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>
|
||||||
34
src/components/rt-toolbar/rt-toolbar.component.scss
Normal file
34
src/components/rt-toolbar/rt-toolbar.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/components/rt-toolbar/rt-toolbar.component.ts
Normal file
96
src/components/rt-toolbar/rt-toolbar.component.ts
Normal 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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-track-changes/index.ts
Normal file
1
src/components/rt-track-changes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-track-changes.component';
|
||||||
@@ -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>
|
||||||
142
src/components/rt-track-changes/rt-track-changes.component.scss
Normal file
142
src/components/rt-track-changes/rt-track-changes.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/rt-variable-picker/index.ts
Normal file
1
src/components/rt-variable-picker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rt-variable-picker.component';
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
47
src/index.ts
Normal 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
1
src/providers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './rich-text-config.provider';
|
||||||
14
src/providers/rich-text-config.provider.ts
Normal file
14
src/providers/rich-text-config.provider.ts
Normal 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 } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
144
src/services/collaboration.service.ts
Normal file
144
src/services/collaboration.service.ts
Normal 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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/services/editor-state.service.ts
Normal file
93
src/services/editor-state.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/services/history.service.ts
Normal file
25
src/services/history.service.ts
Normal 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
6
src/services/index.ts
Normal 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';
|
||||||
83
src/services/markdown.service.ts
Normal file
83
src/services/markdown.service.ts
Normal 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, '');
|
||||||
|
md = md.replace(/<img[^>]*src="([^"]*)"[^>]*\/?>/gi, '');
|
||||||
|
|
||||||
|
// 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(/&/g, '&');
|
||||||
|
md = md.replace(/</g, '<');
|
||||||
|
md = md.replace(/>/g, '>');
|
||||||
|
md = md.replace(/"/g, '"');
|
||||||
|
md = md.replace(/'/g, "'");
|
||||||
|
md = md.replace(/ /g, ' ');
|
||||||
|
|
||||||
|
// Clean up excessive whitespace
|
||||||
|
md = md.replace(/\n{3,}/g, '\n\n');
|
||||||
|
return md.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/services/template.service.ts
Normal file
47
src/services/template.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/services/toolbar.service.ts
Normal file
71
src/services/toolbar.service.ts
Normal 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
2
src/styles/_index.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@forward 'tokens';
|
||||||
|
@forward 'mixins';
|
||||||
224
src/styles/_mixins.scss
Normal file
224
src/styles/_mixins.scss
Normal 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
103
src/styles/_tokens.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/types/collaboration.types.ts
Normal file
85
src/types/collaboration.types.ts
Normal 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
48
src/types/config.types.ts
Normal 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
61
src/types/editor.types.ts
Normal 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
75
src/types/event.types.ts
Normal 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
6
src/types/index.ts
Normal 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';
|
||||||
55
src/types/template.types.ts
Normal file
55
src/types/template.types.ts
Normal 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;
|
||||||
|
}
|
||||||
44
src/types/toolbar.types.ts
Normal file
44
src/types/toolbar.types.ts
Normal 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
36
src/utils/editor.utils.ts
Normal 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
50
src/utils/html.utils.ts
Normal 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
43
src/utils/icons.utils.ts
Normal 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
6
src/utils/index.ts
Normal 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';
|
||||||
69
src/utils/markdown.utils.ts
Normal file
69
src/utils/markdown.utils.ts
Normal 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 };
|
||||||
|
}
|
||||||
57
src/utils/template.utils.ts
Normal file
57
src/utils/template.utils.ts
Normal 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
206
src/utils/toolbar.utils.ts
Normal 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 ', type: 'button' },
|
||||||
|
{ action: 'horizontal-rule', icon: 'minus', label: 'Horizontal rule (---)', type: 'button' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user