Skip to content

Commit

Permalink
feat: add floating toolbar (#914)
Browse files Browse the repository at this point in the history
  • Loading branch information
splincode authored Mar 25, 2024
1 parent bff3301 commit 6b78a58
Show file tree
Hide file tree
Showing 18 changed files with 422 additions and 37 deletions.
7 changes: 7 additions & 0 deletions projects/demo/src/app/app.pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
},
],
},
];
5 changes: 5 additions & 0 deletions projects/demo/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
1 change: 1 addition & 0 deletions projects/demo/src/app/constants/demo-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ export const TuiDemoPath = {
StarterKit: 'starter-kit',
UploadFiles: 'upload-files',
ToolbarBottom: 'toolbar/bottom',
ToolbarFloating: 'toolbar/floating',
} as const;
4 changes: 2 additions & 2 deletions projects/demo/src/app/pages/color-picker/examples/1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
}
8 changes: 8 additions & 0 deletions projects/demo/src/app/pages/starter/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
<tui-doc-demo [sticky]="false">
<tui-editor
[exampleText]="exampleText"
[floatingToolbar]="floating"
[focusable]="focusable"
[formControl]="control"
[pseudoFocus]="pseudoFocused"
Expand Down Expand Up @@ -65,6 +66,13 @@ <h4>Text:</h4>
>
Allowed edit tools
</ng-template>
<ng-template
documentationPropertyName="[floatingToolbar]"
documentationPropertyType="boolean"
[(documentationPropertyValue)]="floating"
>
Floating toolbar
</ng-template>
</tui-doc-documentation>
<tui-doc-documentation heading="CSS customization">
<ng-template
Expand Down
1 change: 1 addition & 0 deletions projects/demo/src/app/pages/starter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export default class TuiEditorStarterPageComponent {
];

protected tools = this.toolsVariants[0];
protected floating = false;

protected get disabled(): boolean {
return this.control.disabled;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<tui-editor
[floatingToolbar]="true"
[formControl]="control"
[tools]="builtInTools"
/>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:root {
--tui-floating-toolbar-max-width: 20rem;
}
61 changes: 61 additions & 0 deletions projects/demo/src/app/pages/toolbar/floating/examples/1/index.ts
Original file line number Diff line number Diff line change
@@ -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(`
<h2>What is Lorem Ipsum?</h2>
<p>
<a
href="https://www.google.com/search?q=wikipedia&sca_esv=563020551&sxsrf=AB5stBhNcprCNZotMYrhf_8rPUA7JwZ4XQ%3A1693989718615&ei=Vjv4ZKGaJaPMwPAPx5m68Ag&ved=0ahUKEwihnbm7y5WBAxUjJhAIHceMDo4Q4dUDCBA&uact=5&oq=wikipedia&gs_lp=Egxnd3Mtd2l6LXNlcnAiCXdpa2lwZWRpYTIKEAAYigUYsQMYQzIKEAAYgAQYFBiHAjIHEAAYigUYQzILEAAYgAQYsQMYgwEyBxAAGIoFGEMyBxAAGIoFGEMyCBAAGIAEGLEDMgcQABiKBRhDMgUQABiABDIFEAAYgARIqDZQAFjRMXAAeAGQAQCYAYEBoAG4B6oBAzMuNrgBA8gBAPgBAcICBxAjGIoFGCfCAhEQLhiABBixAxiDARjHARjRA8ICCxAuGIAEGLEDGIMBwgINEAAYigUYsQMYgwEYQ8ICExAuGIoFGLEDGIMBGMcBGNEDGEPCAgoQLhiKBRjUAhhDwgINEAAYgAQYsQMYgwEYCsICDRAuGIoFGMcBGNEDGEPiAwQYACBBiAYB&sclient=gws-wiz-serp"
>
Lorem Ipsum
</a>
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.
</p>
`);

protected send(): void {
this.dialog.open(this.control.value).subscribe();
}
}
11 changes: 11 additions & 0 deletions projects/demo/src/app/pages/toolbar/floating/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<tui-doc-page
header="Editor"
type="components"
>
<tui-doc-example
id="bottom-toolbar"
heading="Floating"
[component]="component1"
[content]="example1"
/>
</tui-doc-page>
25 changes: 25 additions & 0 deletions projects/demo/src/app/pages/toolbar/floating/index.ts
Original file line number Diff line number Diff line change
@@ -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'),
};
}
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>);
private readonly vcr = inject(ViewContainerRef);
private readonly dropdown = inject(TuiDropdownDirective);

private readonly handler$ = new BehaviorSubject<TuiBooleanHandler<Range>>(
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<Range> | 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;
}
}
Loading

0 comments on commit 6b78a58

Please sign in to comment.