diff --git a/docs/floating-menu.md b/docs/floating-menu.md index e41d7c35..17e2d02f 100644 --- a/docs/floating-menu.md +++ b/docs/floating-menu.md @@ -1,6 +1,6 @@ # Floating Menu -The editor exposes a component which renders a minimal menu. Place it anywhere in the HTML. Technically it is just a wrapper which provides a wrapper and positions it relative to selection. You can render anything inside it as required. +Include the floating menu in the template. ```html @@ -10,8 +10,6 @@ The editor exposes a component which renders a minimal menu. Place it anywhere i OR -HTML - ```html
@@ -19,23 +17,11 @@ HTML
``` -CSS - -```css -.editor { - position: relative; // important -} -``` - -**Note:** Make sure the wrapping element has relative position. Not required if placed inside the editor - ### Default Floating menu -### Floating Menu with custom element. - - +### Floating Menu with custom content. ```html
@@ -45,3 +31,5 @@ CSS
``` + + diff --git a/docs/index.html b/docs/index.html index faa61db8..2d980091 100644 --- a/docs/index.html +++ b/docs/index.html @@ -25,6 +25,7 @@ // themeColor: '#28a745', themeColor: '#0366d6', loadSidebar: true, + subMaxLevel: 2, }; diff --git a/docs/menu.md b/docs/menu.md index 8ca96409..0e160c80 100644 --- a/docs/menu.md +++ b/docs/menu.md @@ -2,7 +2,7 @@ Menu is not part of the editor component. Include `ngx-editor-menu` in your HTML manually. -## Component props +### Component props - **editor** - (`Required`) editor instance - **toolbar** - (`Optional`) @@ -145,3 +145,27 @@ export class CustomMenuComponent implements OnInit { } } ``` + +## Floating Menu + +The editor exposes a component which by default renders a minimal menu. Place it anywhere in the HTML. Technically it is just a wrapper which provides a wrapper and positions it relative to selection. You can render anything inside it as required. + +```html + + + +``` + +OR + +```html +
+ + +
+``` + +### Component props + +- **editor** - (`Required`) editor instance +- **autoPlace** - (`Optional`) positions automatically to the top or bottom based on the space available. `false` by default diff --git a/package-lock.json b/package-lock.json index 0ae0b968..81657043 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "@angular/compiler-cli": "~13.2.0", "@commitlint/cli": "^16.2.1", "@commitlint/config-conventional": "^16.2.1", + "@floating-ui/core": "^0.4.0", + "@floating-ui/dom": "^0.2.0", "@types/jasmine": "~3.10.0", "@types/node": "^12.11.1", "@types/prosemirror-commands": "^1.0.4", @@ -2938,6 +2940,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@floating-ui/core": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.4.0.tgz", + "integrity": "sha512-99Pnb5T2S7PSE8rKHD5fZWWnXE3a2xG9E8eQm8SIMYWJyJoHDJIt0b9SK3UjetWFcepxVG+ZMDxcfbamyx+HSg==", + "dev": true + }, + "node_modules/@floating-ui/dom": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.2.0.tgz", + "integrity": "sha512-hNhTbImiJ8SH9rHsP0qcE9IVxn/maMhwWA5d17p7EkEzVdciayB664Qy2Vxa8RxY/8avE8iVKkfE8sIeg34J0g==", + "dev": true, + "dependencies": { + "@floating-ui/core": "^0.4.0" + } + }, "node_modules/@gar/promisify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz", @@ -18468,6 +18485,21 @@ } } }, + "@floating-ui/core": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-0.4.0.tgz", + "integrity": "sha512-99Pnb5T2S7PSE8rKHD5fZWWnXE3a2xG9E8eQm8SIMYWJyJoHDJIt0b9SK3UjetWFcepxVG+ZMDxcfbamyx+HSg==", + "dev": true + }, + "@floating-ui/dom": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-0.2.0.tgz", + "integrity": "sha512-hNhTbImiJ8SH9rHsP0qcE9IVxn/maMhwWA5d17p7EkEzVdciayB664Qy2Vxa8RxY/8avE8iVKkfE8sIeg34J0g==", + "dev": true, + "requires": { + "@floating-ui/core": "^0.4.0" + } + }, "@gar/promisify": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.2.tgz", diff --git a/package.json b/package.json index d9934d9f..153fec92 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,8 @@ "@angular/compiler-cli": "~13.2.0", "@commitlint/cli": "^16.2.1", "@commitlint/config-conventional": "^16.2.1", + "@floating-ui/core": "^0.4.0", + "@floating-ui/dom": "^0.2.0", "@types/jasmine": "~3.10.0", "@types/node": "^12.11.1", "@types/prosemirror-commands": "^1.0.4", diff --git a/projects/ngx-editor/package.json b/projects/ngx-editor/package.json index 8b2d5fa4..b271e2fa 100644 --- a/projects/ngx-editor/package.json +++ b/projects/ngx-editor/package.json @@ -21,6 +21,8 @@ "rxjs": ">=7.4.0" }, "dependencies": { + "@floating-ui/core": "^0.4.0", + "@floating-ui/dom": "^0.2.0", "@types/prosemirror-commands": "^1.0.4", "@types/prosemirror-history": "^1.0.3", "@types/prosemirror-inputrules":"^1.0.4", diff --git a/projects/ngx-editor/src/lib/modules/menu/floating-menu/floating-menu.component.ts b/projects/ngx-editor/src/lib/modules/menu/floating-menu/floating-menu.component.ts index bd7d5f8f..cc39e086 100644 --- a/projects/ngx-editor/src/lib/modules/menu/floating-menu/floating-menu.component.ts +++ b/projects/ngx-editor/src/lib/modules/menu/floating-menu/floating-menu.component.ts @@ -6,6 +6,8 @@ import { NodeSelection } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { asyncScheduler, fromEvent, Subscription } from 'rxjs'; import { throttleTime } from 'rxjs/operators'; +import { VirtualElement } from '@floating-ui/core'; +import { computePosition, detectOverflow, offset, autoPlacement } from '@floating-ui/dom'; import Editor from '../../../Editor'; import { TBItems } from '../../../types'; @@ -39,6 +41,7 @@ export class FloatingMenuComponent implements OnInit, OnDestroy { } @Input() editor: Editor; + @Input() autoPlace = false; private posLeft = 0; private posTop = 0; @@ -110,52 +113,95 @@ export class FloatingMenuComponent implements OnInit, OnDestroy { this.showMenu = true; } - private calculateBubblePosition(view: EditorView): BubblePosition { + private async calculateBubblePosition(view: EditorView): Promise { const { state: { selection } } = view; - const { from } = selection; - - // the floating bubble itself - const bubbleEl = this.el.nativeElement; - const bubble = bubbleEl.getBoundingClientRect(); - - // The box in which the tooltip is positioned, to use as base - const box = bubbleEl.parentElement.getBoundingClientRect(); + const { from, to } = selection; const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + + const selectionElement: VirtualElement = { + getBoundingClientRect() { + if (selection instanceof NodeSelection) { + const node = view.nodeDOM(from) as HTMLElement + return node.getBoundingClientRect() + } + + const top = start.top + const bottom = end.bottom + const left = start.left + const right = end.right + + return { + x: left, + y: top, + top, + bottom, + left, + right, + width: right - left, + height: bottom - top, + }; + }, + }; - let left = start.left - box.left; - - const overflowsRight = ( - box.right < (start.left + bubble.width) || - bubble.right > box.right - ); - - if (overflowsRight) { - left = box.width - bubble.width; - } - - if (left < 0) { - left = 0; - } + // the floating bubble itself + const bubbleEl = this.el.nativeElement; - const bubbleHeight = bubble.height + parseInt(getComputedStyle(bubbleEl).marginBottom, 10); - const top = (start.top - box.top) - bubbleHeight; + const { x: left, y: top } = await computePosition(selectionElement, bubbleEl, { + placement: 'top', + middleware: [ + offset(5), + this.autoPlace && autoPlacement({ + boundary: view.dom, + padding: 5, + allowedPlacements: ['top', 'bottom'] + }), + { + // prevent overflow on right and left side + // since only top and bottom placements are allowed + // autoplacement can't handle overflows on the right and left + name: 'overflowMiddleware', + async fn(middlewareArgs) { + const overflow = await detectOverflow(middlewareArgs, { + boundary: view.dom, + padding: 5, + }); + + // overflows left + if (overflow.left > 0) { + return { + x: middlewareArgs.x + overflow.left, + } + } + + // overflows right + if (overflow.right > 0) { + return { + x: middlewareArgs.x - overflow.right, + } + } + + return {}; + }, + } + ].filter(Boolean) + }) return { left, top - }; + } } - private update(view: EditorView): void { + private canShowMenu(view: EditorView): Boolean { const { state } = view; const { selection } = state; const { empty } = selection; if (selection instanceof NodeSelection) { if (selection.node.type.name === 'image') { - this.hide(); - return; + return false; } } @@ -163,15 +209,29 @@ export class FloatingMenuComponent implements OnInit, OnDestroy { if (!hasFocus || empty || this.dragging) { this.hide(); - return; + return false; + } + + return true + } + + private update(view: EditorView): void { + const canShowMenu = this.canShowMenu(view) + + if (!canShowMenu) { + return this.hide() } - const { top, left } = this.calculateBubblePosition(this.view); + this.calculateBubblePosition(this.view).then(({ top, left }) => { + if (!this.canShowMenu) { + return this.hide() + } - this.posLeft = left; - this.posTop = top; + this.posLeft = left; + this.posTop = top; - this.show(); + this.show(); + }); } ngOnInit(): void {