From 6b78a58d56be440e2514244cec18bbb37f2be3f5 Mon Sep 17 00:00:00 2001 From: Maksim Ivanov Date: Mon, 25 Mar 2024 19:51:36 +0300 Subject: [PATCH] feat: add floating toolbar (#914) --- projects/demo/src/app/app.pages.ts | 7 + projects/demo/src/app/app.routes.ts | 5 + projects/demo/src/app/constants/demo-path.ts | 1 + .../pages/color-picker/examples/1/index.ts | 4 +- .../demo/src/app/pages/starter/index.html | 8 + projects/demo/src/app/pages/starter/index.ts | 1 + .../toolbar/floating/examples/1/index.html | 5 + .../toolbar/floating/examples/1/index.less | 3 + .../toolbar/floating/examples/1/index.ts | 61 ++++++ .../src/app/pages/toolbar/floating/index.html | 11 + .../src/app/pages/toolbar/floating/index.ts | 25 +++ .../dropdown/dropdown-toolbar.directive.ts | 205 ++++++++++++++++++ .../components/editor/editor.component.html | 86 +++++--- .../components/editor/editor.component.less | 8 + .../src/components/editor/editor.component.ts | 22 +- .../src/constants/default-editor-colors.ts | 2 +- projects/tui-editor/src/index.ts | 1 + .../tui-editor/src/tokens/editor-options.ts | 4 +- 18 files changed, 422 insertions(+), 37 deletions(-) create mode 100644 projects/demo/src/app/pages/toolbar/floating/examples/1/index.html create mode 100644 projects/demo/src/app/pages/toolbar/floating/examples/1/index.less create mode 100644 projects/demo/src/app/pages/toolbar/floating/examples/1/index.ts create mode 100644 projects/demo/src/app/pages/toolbar/floating/index.html create mode 100644 projects/demo/src/app/pages/toolbar/floating/index.ts create mode 100644 projects/tui-editor/src/components/editor/dropdown/dropdown-toolbar.directive.ts diff --git a/projects/demo/src/app/app.pages.ts b/projects/demo/src/app/app.pages.ts index cf2e82235..383d20ac5 100644 --- a/projects/demo/src/app/app.pages.ts +++ b/projects/demo/src/app/app.pages.ts @@ -211,6 +211,13 @@ export const DEMO_PAGES: TuiDocPages = [ 'editor, toolbar, bottom, wysiwyg, редактор, текст, подсветка, html, rich, text', route: `/${TuiDemoPath.ToolbarBottom}`, }, + { + section: 'Examples', + title: 'Floating', + keywords: + 'editor, toolbar, floating, wysiwyg, редактор, текст, подсветка, html, rich, text', + route: `/${TuiDemoPath.ToolbarFloating}`, + }, ], }, ]; diff --git a/projects/demo/src/app/app.routes.ts b/projects/demo/src/app/app.routes.ts index ca69b5360..dca9775d8 100644 --- a/projects/demo/src/app/app.routes.ts +++ b/projects/demo/src/app/app.routes.ts @@ -134,6 +134,11 @@ export const routes: Routes = [ loadComponent: async () => import('./pages/toolbar/bottom'), title: 'Editor — Toolbar', }), + route({ + path: TuiDemoPath.ToolbarFloating, + loadComponent: async () => import('./pages/toolbar/floating'), + title: 'Editor — Toolbar', + }), route({ path: TuiDemoPath.Changelog, loadComponent: async () => import('./pages/changelog'), diff --git a/projects/demo/src/app/constants/demo-path.ts b/projects/demo/src/app/constants/demo-path.ts index 2d73a0477..b6c1bf191 100644 --- a/projects/demo/src/app/constants/demo-path.ts +++ b/projects/demo/src/app/constants/demo-path.ts @@ -25,4 +25,5 @@ export const TuiDemoPath = { StarterKit: 'starter-kit', UploadFiles: 'upload-files', ToolbarBottom: 'toolbar/bottom', + ToolbarFloating: 'toolbar/floating', } as const; diff --git a/projects/demo/src/app/pages/color-picker/examples/1/index.ts b/projects/demo/src/app/pages/color-picker/examples/1/index.ts index 848b73e69..e7a1f2fc4 100644 --- a/projects/demo/src/app/pages/color-picker/examples/1/index.ts +++ b/projects/demo/src/app/pages/color-picker/examples/1/index.ts @@ -2,7 +2,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import {FormsModule} from '@angular/forms'; import {TuiTextfieldControllerModule} from '@taiga-ui/core'; import { - TUI_EDITOR_DEFAULT_EDITOR_TOOLS, + TUI_EDITOR_DEFAULT_EDITOR_COLORS, TuiInputColorComponent, } from '@tinkoff/tui-editor'; @@ -15,5 +15,5 @@ import { export default class ExampleComponent { protected color = '#ffdd2d'; - protected readonly palette = TUI_EDITOR_DEFAULT_EDITOR_TOOLS; + protected readonly palette = TUI_EDITOR_DEFAULT_EDITOR_COLORS; } diff --git a/projects/demo/src/app/pages/starter/index.html b/projects/demo/src/app/pages/starter/index.html index 5cb2ce9c6..74fda4875 100644 --- a/projects/demo/src/app/pages/starter/index.html +++ b/projects/demo/src/app/pages/starter/index.html @@ -6,6 +6,7 @@ Text: > Allowed edit tools + + Floating toolbar + diff --git a/projects/demo/src/app/pages/toolbar/floating/examples/1/index.less b/projects/demo/src/app/pages/toolbar/floating/examples/1/index.less new file mode 100644 index 000000000..23840d9cd --- /dev/null +++ b/projects/demo/src/app/pages/toolbar/floating/examples/1/index.less @@ -0,0 +1,3 @@ +:root { + --tui-floating-toolbar-max-width: 20rem; +} diff --git a/projects/demo/src/app/pages/toolbar/floating/examples/1/index.ts b/projects/demo/src/app/pages/toolbar/floating/examples/1/index.ts new file mode 100644 index 000000000..8daf5a5d4 --- /dev/null +++ b/projects/demo/src/app/pages/toolbar/floating/examples/1/index.ts @@ -0,0 +1,61 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + Injector, + ViewEncapsulation, +} from '@angular/core'; +import {FormControl, ReactiveFormsModule} from '@angular/forms'; +import {TuiDialogService} from '@taiga-ui/core'; +import { + TUI_EDITOR_DEFAULT_EXTENSIONS, + TUI_EDITOR_DEFAULT_TOOLS, + TUI_EDITOR_EXTENSIONS, + TuiEditorComponent, +} from '@tinkoff/tui-editor'; + +@Component({ + standalone: true, + imports: [TuiEditorComponent, ReactiveFormsModule], + templateUrl: './index.html', + styleUrls: ['./index.less'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: TUI_EDITOR_EXTENSIONS, + deps: [Injector], + useFactory: (injector: Injector) => [ + ...TUI_EDITOR_DEFAULT_EXTENSIONS, + import('@tinkoff/tui-editor').then(({tuiCreateImageEditorExtension}) => + tuiCreateImageEditorExtension({injector}), + ), + ], + }, + ], +}) +export default class ExampleComponent { + private readonly dialog = inject(TuiDialogService); + + protected readonly builtInTools = TUI_EDITOR_DEFAULT_TOOLS; + + protected readonly control = new FormControl(` +

What is Lorem Ipsum?

+

+ + Lorem Ipsum + + is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy + text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen + book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially + unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and + more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum. +

+ `); + + protected send(): void { + this.dialog.open(this.control.value).subscribe(); + } +} diff --git a/projects/demo/src/app/pages/toolbar/floating/index.html b/projects/demo/src/app/pages/toolbar/floating/index.html new file mode 100644 index 000000000..903a9d460 --- /dev/null +++ b/projects/demo/src/app/pages/toolbar/floating/index.html @@ -0,0 +1,11 @@ + + + diff --git a/projects/demo/src/app/pages/toolbar/floating/index.ts b/projects/demo/src/app/pages/toolbar/floating/index.ts new file mode 100644 index 000000000..1e88d4c0f --- /dev/null +++ b/projects/demo/src/app/pages/toolbar/floating/index.ts @@ -0,0 +1,25 @@ +import {ChangeDetectionStrategy, Component} from '@angular/core'; +import type {TuiDocExample} from '@taiga-ui/addon-doc'; +import {TuiAddonDocModule} from '@taiga-ui/addon-doc'; +import {TUI_EDITOR_DEFAULT_EXTENSIONS, TUI_EDITOR_EXTENSIONS} from '@tinkoff/tui-editor'; + +@Component({ + standalone: true, + imports: [TuiAddonDocModule], + templateUrl: './index.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: TUI_EDITOR_EXTENSIONS, + useValue: TUI_EDITOR_DEFAULT_EXTENSIONS, + }, + ], +}) +export default class ExampleComponent { + protected readonly component1 = import('./examples/1'); + protected readonly example1: TuiDocExample = { + TypeScript: import('./examples/1/index.ts?raw'), + HTML: import('./examples/1/index.html?raw'), + LESS: import('./examples/1/index.less?raw'), + }; +} diff --git a/projects/tui-editor/src/components/editor/dropdown/dropdown-toolbar.directive.ts b/projects/tui-editor/src/components/editor/dropdown/dropdown-toolbar.directive.ts new file mode 100644 index 000000000..3be916fc0 --- /dev/null +++ b/projects/tui-editor/src/components/editor/dropdown/dropdown-toolbar.directive.ts @@ -0,0 +1,205 @@ +import {DOCUMENT} from '@angular/common'; +import type {OnDestroy} from '@angular/core'; +import {Directive, ElementRef, inject, Input, ViewContainerRef} from '@angular/core'; +import type {TuiBooleanHandler} from '@taiga-ui/cdk'; +import { + CHAR_NO_BREAK_SPACE, + CHAR_ZERO_WIDTH_SPACE, + EMPTY_CLIENT_RECT, + TUI_RANGE, + TUI_TRUE_HANDLER, + tuiGetNativeFocused, + tuiIsElement, + tuiIsString, + tuiIsTextfield, + tuiIsTextNode, + tuiPx, +} from '@taiga-ui/cdk'; +import type {TuiRectAccessor} from '@taiga-ui/core'; +import { + TUI_SELECTION_STREAM, + tuiAsDriver, + tuiAsRectAccessor, + TuiDriver, + TuiDropdownDirective, + tuiGetWordRange, +} from '@taiga-ui/core'; +import {BehaviorSubject, combineLatest, distinctUntilChanged, map} from 'rxjs'; + +@Directive({ + standalone: true, + selector: '[tuiToolbarDropdown]', + providers: [ + tuiAsDriver(TuiDropdownToolbarDirective), + tuiAsRectAccessor(TuiDropdownToolbarDirective), + ], +}) +export class TuiDropdownToolbarDirective + extends TuiDriver + implements TuiRectAccessor, OnDestroy +{ + private range = inject(TUI_RANGE); + private readonly doc = inject(DOCUMENT); + private readonly selection$ = inject(TUI_SELECTION_STREAM); + private readonly el = inject(ElementRef); + private readonly vcr = inject(ViewContainerRef); + private readonly dropdown = inject(TuiDropdownDirective); + + private readonly handler$ = new BehaviorSubject>( + TUI_TRUE_HANDLER, + ); + + private readonly stream$ = combineLatest([ + this.handler$, + this.selection$.pipe( + map(() => this.getRange()), + distinctUntilChanged( + (x, y) => x.startOffset === y.startOffset && x.endOffset === y.endOffset, + ), + ), + ]).pipe( + map(([handler, range]) => { + const contained = + this.el.nativeElement.contains(range.commonAncestorContainer) || + range.commonAncestorContainer.parentElement?.closest('tui-dropdown'); + + this.range = + contained && tuiIsTextNode(range.commonAncestorContainer) + ? range + : this.range; + + return (contained && handler(this.range)) || this.inDropdown(range); + }), + ); + + private ghost?: HTMLElement; + + @Input('tuiToolbarDropdownPosition') + public position: 'selection' | 'tag' | 'word' = 'selection'; + + public readonly type = 'dropdown'; + + constructor() { + super(subscriber => this.stream$.subscribe(subscriber)); + } + + @Input() + public set tuiToolbarDropdown(visible: TuiBooleanHandler | string) { + if (!tuiIsString(visible)) { + this.handler$.next(visible); + } + } + + public getClientRect(): ClientRect { + switch (this.position) { + case 'tag': { + const {commonAncestorContainer} = this.range; + const element = tuiIsElement(commonAncestorContainer) + ? commonAncestorContainer + : commonAncestorContainer.parentNode; + + return element && tuiIsElement(element) + ? element.getBoundingClientRect() + : EMPTY_CLIENT_RECT; + } + case 'word': + return tuiGetWordRange(this.range).getBoundingClientRect(); + default: { + const rect = this.range.getBoundingClientRect(); + + if ( + rect.x === 0 && + rect.y === 0 && + rect.width === 0 && + rect.height === 0 + ) { + return ( + this.el.nativeElement.querySelector('p') ?? this.el.nativeElement + ).getBoundingClientRect(); + } + + return rect; + } + } + } + + public ngOnDestroy(): void { + if (this.ghost) { + this.vcr.element.nativeElement.removeChild(this.ghost); + } + } + + private getRange(): Range { + const active = tuiGetNativeFocused(this.doc); + const selection = this.doc.getSelection(); + const range = + active && tuiIsTextfield(active) && this.el.nativeElement.contains(active) + ? this.veryVerySadInputFix(active) + : (selection?.rangeCount && selection.getRangeAt(0)) || this.range; + + return range.cloneRange(); + } + + /** + * Check if Node is inside dropdown + */ + private boxContains(node: Node): boolean { + return !!this.dropdown.dropdownBoxRef?.location.nativeElement.contains(node); + } + + /** + * Check if given range is at least partially inside dropdown + */ + private inDropdown(range: Range): boolean { + const {startContainer, endContainer} = range; + const {nativeElement} = this.el; + const inDropdown = this.boxContains(range.commonAncestorContainer); + const hostToDropdown = + this.boxContains(endContainer) && nativeElement.contains(startContainer); + const dropdownToHost = + this.boxContains(startContainer) && nativeElement.contains(endContainer); + + return inDropdown || hostToDropdown || dropdownToHost; + } + + private veryVerySadInputFix(element: HTMLInputElement | HTMLTextAreaElement): Range { + const {ghost = this.initGhost(element)} = this; + const {top, left, width, height} = element.getBoundingClientRect(); + const {selectionStart, selectionEnd, value} = element; + const range = this.doc.createRange(); + const hostRect = this.el.nativeElement.getBoundingClientRect(); + + ghost.style.top = tuiPx(top - hostRect.top); + ghost.style.left = tuiPx(left - hostRect.left); + ghost.style.width = tuiPx(width); + ghost.style.height = tuiPx(height); + ghost.textContent = CHAR_ZERO_WIDTH_SPACE + value + CHAR_NO_BREAK_SPACE; + + range.setStart(ghost.firstChild as Node, selectionStart || 0); + range.setEnd(ghost.firstChild as Node, selectionEnd || 0); + + return range; + } + + /** + * Create an invisible DIV styled exactly like input/textarea element inside directive + */ + private initGhost(element: HTMLInputElement | HTMLTextAreaElement): HTMLElement { + const ghost = this.doc.createElement('div'); + const {font, letterSpacing, textTransform, padding} = getComputedStyle(element); + + ghost.style.position = 'absolute'; + ghost.style.pointerEvents = 'none'; + ghost.style.opacity = '0'; + ghost.style.whiteSpace = 'pre-wrap'; + ghost.style.font = font; + ghost.style.letterSpacing = letterSpacing; + ghost.style.textTransform = textTransform; + ghost.style.padding = padding; + + this.vcr.element.nativeElement.appendChild(ghost); + this.ghost = ghost; + + return ghost; + } +} diff --git a/projects/tui-editor/src/components/editor/editor.component.html b/projects/tui-editor/src/components/editor/editor.component.html index 13ef6d86e..fd46b454b 100644 --- a/projects/tui-editor/src/components/editor/editor.component.html +++ b/projects/tui-editor/src/components/editor/editor.component.html @@ -12,7 +12,7 @@ >
- -
+ - -
+ +
+ + + + - - - + +
+ +
+ + +
+ + + - - + +
+
+
+ diff --git a/projects/tui-editor/src/components/editor/editor.component.less b/projects/tui-editor/src/components/editor/editor.component.less index 93c32655d..12d521f87 100644 --- a/projects/tui-editor/src/components/editor/editor.component.less +++ b/projects/tui-editor/src/components/editor/editor.component.less @@ -63,3 +63,11 @@ box-sizing: border-box; flex: 1; } + +.t-floating { + &, + ::ng-deep .t-tools-wrapper { + max-width: var(--tui-floating-toolbar-max-width, 31.25rem); + flex-wrap: nowrap; + } +} diff --git a/projects/tui-editor/src/components/editor/editor.component.ts b/projects/tui-editor/src/components/editor/editor.component.ts index 41dc56a17..2c17a6334 100644 --- a/projects/tui-editor/src/components/editor/editor.component.ts +++ b/projects/tui-editor/src/components/editor/editor.component.ts @@ -1,4 +1,4 @@ -import {AsyncPipe, DOCUMENT, NgIf} from '@angular/common'; +import {AsyncPipe, DOCUMENT, NgIf, NgTemplateOutlet} from '@angular/common'; import type {OnDestroy} from '@angular/core'; import { ChangeDetectionStrategy, @@ -14,6 +14,7 @@ import type {TuiBooleanHandler, TuiFocusableElementAccessor} from '@taiga-ui/cdk import { AbstractTuiControl, TUI_FALSE_HANDLER, + TUI_TRUE_HANDLER, TuiActiveZoneDirective, tuiAsFocusableItemAccessor, tuiAutoFocusOptionsProvider, @@ -22,7 +23,6 @@ import { TUI_ANIMATIONS_DEFAULT_DURATION, TuiDropdownDirective, TuiDropdownOptionsDirective, - TuiDropdownSelectionDirective, TuiScrollbarComponent, TuiWrapperModule, } from '@taiga-ui/core'; @@ -41,6 +41,7 @@ import {tuiIsSafeLinkRange} from '../../utils/safe-link-range'; import {TuiEditLinkComponent} from '../edit-link/edit-link.component'; import {TuiEditorSocketComponent} from '../editor-socket/editor-socket.component'; import {TuiToolbarComponent} from '../toolbar/toolbar.component'; +import {TuiDropdownToolbarDirective} from './dropdown/dropdown-toolbar.directive'; import {TUI_EDITOR_PROVIDERS} from './editor.providers'; import {TuiEditorPortalDirective} from './portal/editor-portal.directive'; import {TuiEditorPortalHostComponent} from './portal/editor-portal-host.component'; @@ -59,10 +60,11 @@ import {TuiEditorPortalHostComponent} from './portal/editor-portal-host.componen TuiEditLinkComponent, TuiEditorPortalHostComponent, TuiEditorPortalDirective, - TuiDropdownSelectionDirective, TuiTiptapEditorDirective, TuiEditorSocketComponent, TuiToolbarComponent, + NgTemplateOutlet, + TuiDropdownToolbarDirective, ], templateUrl: './editor.component.html', styleUrls: ['./editor.component.less'], @@ -86,6 +88,9 @@ export class TuiEditorComponent @Input() public exampleText = ''; + @Input() + public floatingToolbar = false; + @Input() public tools: readonly TuiEditorTool[] = TUI_EDITOR_DEFAULT_TOOLS; @@ -145,6 +150,10 @@ export class TuiEditorComponent } protected get dropdownSelectionHandler(): TuiBooleanHandler { + if (this.floatingToolbar) { + return TUI_TRUE_HANDLER; + } + return this.focused ? this.isSelectionLink : TUI_FALSE_HANDLER; } @@ -158,6 +167,13 @@ export class TuiEditorComponent ); } + protected get isLinkSelected(): boolean { + const node = this.doc.getSelection()?.focusNode?.parentNode; + const element = node?.nodeName.toLowerCase(); + + return element === 'a' || !!node?.parentElement?.closest('tui-edit-link'); + } + protected onActiveZone(focused: boolean): void { this.focused = focused; this.updateFocused(focused); diff --git a/projects/tui-editor/src/constants/default-editor-colors.ts b/projects/tui-editor/src/constants/default-editor-colors.ts index f79e4399b..88f6c9df2 100644 --- a/projects/tui-editor/src/constants/default-editor-colors.ts +++ b/projects/tui-editor/src/constants/default-editor-colors.ts @@ -1,4 +1,4 @@ -export const TUI_EDITOR_DEFAULT_EDITOR_TOOLS = new Map([ +export const TUI_EDITOR_DEFAULT_EDITOR_COLORS = new Map([ ['color-black-100', '#909090'], ['color-black-200', '#666666'], ['color-black-300', '#333333'], diff --git a/projects/tui-editor/src/index.ts b/projects/tui-editor/src/index.ts index 1cd25fb35..3386e3400 100644 --- a/projects/tui-editor/src/index.ts +++ b/projects/tui-editor/src/index.ts @@ -9,6 +9,7 @@ export * from './components/color-selector/palette/palette.component'; export * from './components/edit-link/pipes/filter-anchors.pipe'; export * from './components/edit-link/pipes/short-url.pipe'; export * from './components/edit-link/utils/edit-link-parse-url'; +export * from './components/editor/dropdown/dropdown-toolbar.directive'; export * from './components/editor/editor.component'; export * from './components/editor/editor.providers'; export * from './components/editor/portal/editor-portal.directive'; diff --git a/projects/tui-editor/src/tokens/editor-options.ts b/projects/tui-editor/src/tokens/editor-options.ts index 1dad1e0a8..0d2d7ed80 100644 --- a/projects/tui-editor/src/tokens/editor-options.ts +++ b/projects/tui-editor/src/tokens/editor-options.ts @@ -3,7 +3,7 @@ import {tuiCreateOptions, tuiProvideOptions} from '@taiga-ui/cdk'; import { EDITOR_BLANK_COLOR, - TUI_EDITOR_DEFAULT_EDITOR_TOOLS, + TUI_EDITOR_DEFAULT_EDITOR_COLORS, } from '../constants/default-editor-colors'; import {tuiDefaultFontOptionsHandler} from '../constants/default-font-options-handler'; import type {TuiEditorLinkOptions} from '../constants/default-link-options-handler'; @@ -71,7 +71,7 @@ export const TUI_EDITOR_DEFAULT_OPTIONS: TuiEditorOptions = { appearence: 'textfield', spellcheck: false, enableDefaultStyles: true, - colors: TUI_EDITOR_DEFAULT_EDITOR_TOOLS, + colors: TUI_EDITOR_DEFAULT_EDITOR_COLORS, blankColor: EDITOR_BLANK_COLOR, linkOptions: TUI_DEFAULT_LINK_OPTIONS, fontOptions: tuiDefaultFontOptionsHandler,