From 87e8d50fd05742ee9c0698ec4d5b8f656ea2e398 Mon Sep 17 00:00:00 2001 From: sibiraj-s Date: Mon, 14 Sep 2020 08:12:34 +0530 Subject: [PATCH] feat: add option to remove link --- demo/src/app/doc.ts | 24 +++- demo/src/app/plugins/index.ts | 7 +- package-lock.json | 12 +- package.json | 6 +- src/lib/ngx-editor.component.scss | 61 +++++++- src/lib/prosemirror/helpers/bubblePosition.ts | 28 ++++ .../prosemirror/helpers/getSelectionMarks.ts | 16 +++ src/lib/prosemirror/helpers/index.ts | 1 + src/lib/prosemirror/plugins/index.ts | 1 + src/lib/prosemirror/plugins/link.ts | 136 ++++++++++++++++++ src/lib/prosemirror/plugins/menu/menu.ts | 1 - src/lib/utils/icons/index.ts | 4 +- src/lib/utils/icons/link.ts | 3 + 13 files changed, 281 insertions(+), 19 deletions(-) create mode 100644 src/lib/prosemirror/helpers/bubblePosition.ts create mode 100644 src/lib/prosemirror/helpers/getSelectionMarks.ts create mode 100644 src/lib/prosemirror/plugins/link.ts create mode 100644 src/lib/utils/icons/link.ts diff --git a/demo/src/app/doc.ts b/demo/src/app/doc.ts index 21f88aa1..41a2e8bb 100644 --- a/demo/src/app/doc.ts +++ b/demo/src/app/doc.ts @@ -47,7 +47,7 @@ export default { { type: 'link', attrs: { - href: 'http://codemirror.net', + href: 'https://codemirror.net', title: null } } @@ -56,7 +56,24 @@ export default { }, { type: 'text', - text: ' code editor, which provides syntax highlighting, auto-indentation, and similar.' + text: ' code editor, which provides ' + }, + { + type: 'text', + marks: [ + { + type: 'link', + attrs: { + href: 'https://en.wikipedia.org', + title: '' + } + } + ], + text: 'syntax highlighting' + }, + { + type: 'text', + text: ', auto-indentation, and similar.' } ] }, @@ -74,8 +91,7 @@ export default { content: [ { type: 'text', - text: - 'The content of the code editor is kept in sync with the content of the code block in the rich text editor, so that it is as if you\'re directly editing the outer document, using a more convenient interface.' + text: 'The content of the code editor is kept in sync with the content of the code block in the rich text editor, so that it is as if you\'re directly editing the outer document, using a more convenient interface.' } ] } diff --git a/demo/src/app/plugins/index.ts b/demo/src/app/plugins/index.ts index 74503cc4..073dcb7f 100644 --- a/demo/src/app/plugins/index.ts +++ b/demo/src/app/plugins/index.ts @@ -1,11 +1,11 @@ -import { menu, placeholder } from 'ngx-editor'; - import { undo, redo, history } from 'prosemirror-history'; import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; import { keymap } from 'prosemirror-keymap'; import { toggleMark, baseKeymap } from 'prosemirror-commands'; import { Plugin } from 'prosemirror-state'; +import { menu, placeholder, link } from 'ngx-editor'; + import codemirrorMenu from './menu/codemirror'; import { buildInputRules } from './input-rules'; import schema from '../schema'; @@ -72,7 +72,8 @@ const getPlugins = (): Plugin[] => { blockquote: 'Quote' } }), - placeholder('Type Something here...') + placeholder('Type Something here...'), + link(), ]; return plugins; diff --git a/package-lock.json b/package-lock.json index 468521ab..eb00a9e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12156,9 +12156,9 @@ } }, "prosemirror-model": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.11.0.tgz", - "integrity": "sha512-GqoAz/mIYjdv8gVYJ8mWFKpHoTxn/lXq4tXJ6bTVxs+rem2LzMYXrNVXfucGtfsgqsJlRIgng/ByG9j7Q8XDrg==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.11.2.tgz", + "integrity": "sha512-+gM+x1VUfGAyKR/g0bK7FC46fVNq0xVVL859QAQ7my2p5HzKrPps/pSbYn7T50XTG2r2IhZJChsUFUBHtcoN0Q==", "dev": true, "requires": { "orderedmap": "^1.1.0" @@ -12203,9 +12203,9 @@ } }, "prosemirror-view": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.15.6.tgz", - "integrity": "sha512-9FBFB+rK5pvvzHsHOacy0T/Jf+OxZSzY8tSlQiur3SZwAVaNVQm+fl23V/6gU2dHBnreGxjYx9jK+F3XPsPCGw==", + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.15.7.tgz", + "integrity": "sha512-fSSXphXg+82qb4xNsHT0mX6ro0Wu1/l+WIFO5jYfyjd42r6ZWSg0gFItLgqOVPxoKQOlrPJUhrozTxiqx0EXOg==", "dev": true, "requires": { "prosemirror-model": "^1.1.0", diff --git a/package.json b/package.json index 2d088fc2..678fb8ce 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@types/node": "^12.11.1", "@types/prosemirror-commands": "^1.0.3", "@types/prosemirror-history": "^1.0.1", - "@types/prosemirror-inputrules": "^1.0.2", + "@types/prosemirror-inputrules": "^1.0.3", "@types/prosemirror-keymap": "^1.0.3", "@types/prosemirror-model": "^1.7.2", "@types/prosemirror-schema-basic": "^1.0.1", @@ -79,11 +79,11 @@ "prosemirror-history": "^1.1.3", "prosemirror-inputrules": "^1.1.2", "prosemirror-keymap": "^1.1.4", - "prosemirror-model": "^1.11.0", + "prosemirror-model": "^1.11.2", "prosemirror-schema-basic": "^1.1.2", "prosemirror-schema-list": "^1.1.4", "prosemirror-state": "^1.3.3", - "prosemirror-view": "^1.15.6", + "prosemirror-view": "^1.15.7", "protractor": "~7.0.0", "ts-node": "~8.3.0", "tslint": "~6.1.0", diff --git a/src/lib/ngx-editor.component.scss b/src/lib/ngx-editor.component.scss index fce031db..f383319c 100644 --- a/src/lib/ngx-editor.component.scss +++ b/src/lib/ngx-editor.component.scss @@ -23,6 +23,7 @@ $menubar-text-padding: 0 $menu-item-spacing; background-clip: padding-box; border-radius: $border-radius; border: 2px solid rgba(0, 0, 0, 0.2); + position: relative; } .NgxEditor__MenuBar { @@ -48,7 +49,10 @@ $menubar-text-padding: 0 $menu-item-spacing; height: $icon-size; width: $icon-size; transition: 0.3s ease-in-out; - margin-right: 2px; + + & + #{ $self }--Icon { + margin-left: 2px; + } } &#{ $self }--Text { @@ -171,6 +175,61 @@ $menubar-text-padding: 0 $menu-item-spacing; } } +.NgxEditor__FloatingBubble { + position: absolute; + z-index: 20; + background: white; + border: 1px solid silver; + border-radius: 4px; + padding: 0.3rem; + margin-bottom: 0.3rem; + transform: translateX(-50%); + display: flex; + + &::before, + &::after { + content: ""; + height: 0; + width: 0; + position: absolute; + left: 50%; + margin-left: -5px; + border: 5px solid transparent; + border-bottom-width: 0; + } + + &::before { + bottom: -6px; + border-top-color: silver; + } + + &::after { + bottom: -4.5px; + border-top-color: white; + } + + a { + display: inline-block; + max-width: 15rem; + overflow: hidden; + text-overflow: ellipsis; + margin-left: 2px; + } + + .commands { + display: flex; + margin-left: 0.5rem; + + .command { + border: none; + + &:hover { + text-decoration: underline; + } + } + } +} + // prosemirror .ProseMirror { outline: none; diff --git a/src/lib/prosemirror/helpers/bubblePosition.ts b/src/lib/prosemirror/helpers/bubblePosition.ts new file mode 100644 index 00000000..b3e5250c --- /dev/null +++ b/src/lib/prosemirror/helpers/bubblePosition.ts @@ -0,0 +1,28 @@ +import { EditorView } from 'prosemirror-view'; + +interface TooltipPosition { + bottom: number; + left: number; +} + +export const calculateBubblePos = (view: EditorView, toolTipEl: HTMLElement): TooltipPosition => { + const { state: { selection } } = view; + const { from, to } = selection; + + // These are in screen coordinates + const start = view.coordsAtPos(from); + const end = view.coordsAtPos(to); + + // The box in which the tooltip is positioned, to use as base + const parent = toolTipEl.offsetParent; + const box = parent.getBoundingClientRect(); + + // Find a center-ish x position from the selection endpoints (when + // crossing lines, end may be more to the left) + const left = Math.max((start.left + end.left) / 2, start.left + 3); + + return { + left: left - box.left, + bottom: box.bottom - start.top + }; +}; diff --git a/src/lib/prosemirror/helpers/getSelectionMarks.ts b/src/lib/prosemirror/helpers/getSelectionMarks.ts new file mode 100644 index 00000000..dfa54946 --- /dev/null +++ b/src/lib/prosemirror/helpers/getSelectionMarks.ts @@ -0,0 +1,16 @@ +import { EditorState } from 'prosemirror-state'; +import { Mark } from 'prosemirror-model'; + +export const getSelectionMarks = (state: EditorState): Mark[] => { + let marks: Mark[] = []; + + const { selection: { from, to } } = state; + + state.doc.nodesBetween(from, to, node => { + marks = [...marks, ...node.marks]; + }); + + return marks; +}; + +export default getSelectionMarks; diff --git a/src/lib/prosemirror/helpers/index.ts b/src/lib/prosemirror/helpers/index.ts index bc291c55..b033d9c1 100644 --- a/src/lib/prosemirror/helpers/index.ts +++ b/src/lib/prosemirror/helpers/index.ts @@ -1,3 +1,4 @@ export * from './isMarkActive'; export * from './isNodeActive'; export * from './isListItem'; +export * from './getSelectionMarks'; diff --git a/src/lib/prosemirror/plugins/index.ts b/src/lib/prosemirror/plugins/index.ts index 2135df8d..74809bd8 100644 --- a/src/lib/prosemirror/plugins/index.ts +++ b/src/lib/prosemirror/plugins/index.ts @@ -1,2 +1,3 @@ export { default as placeholder } from './placeholder'; export { default as menu } from './menu'; +export { default as link } from './link'; diff --git a/src/lib/prosemirror/plugins/link.ts b/src/lib/prosemirror/plugins/link.ts new file mode 100644 index 00000000..69914877 --- /dev/null +++ b/src/lib/prosemirror/plugins/link.ts @@ -0,0 +1,136 @@ +import { EditorView } from 'prosemirror-view'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Mark } from 'prosemirror-model'; + +import { calculateBubblePos } from '../helpers/bubblePosition'; +import { isMarkActive, getSelectionMarks } from '../helpers'; + +class FloatingOptionsView { + bubbleEL: HTMLElement; + + constructor(view: EditorView) { + this.render(view); + this.update(view); + } + + render(view: EditorView): void { + this.bubbleEL = document.createElement('div'); + this.bubbleEL.className = 'NgxEditor__FloatingBubble'; + view.dom.parentNode.appendChild(this.bubbleEL); + } + + setDomPosition(view: EditorView): void { + // Otherwise, reposition it and update its content + this.bubbleEL.style.display = ''; + + const { bottom, left } = calculateBubblePos(view, this.bubbleEL); + this.bubbleEL.style.left = left + 'px'; + this.bubbleEL.style.bottom = bottom + 'px'; + } + + createLinkNode(item: Mark, removeCB: (e: MouseEvent) => void): DocumentFragment { + const el = document.createDocumentFragment(); + + const link = document.createElement('a'); + link.href = item.attrs.href; + link.target = '_blank'; + link.innerText = item.attrs.href; + link.title = item.attrs.href; + + const commands = document.createElement('div'); + commands.classList.add('commands'); + + const editOpt = document.createElement('button'); + editOpt.classList.add('command'); + editOpt.textContent = 'Edit'; + + const removeOpt = document.createElement('button'); + removeOpt.classList.add('command'); + removeOpt.textContent = 'Remove'; + + removeOpt.onclick = removeCB; + + // commands.appendChild(editOpt); + commands.appendChild(removeOpt); + + el.appendChild(link); + el.appendChild(commands); + + return el; + } + + clearBubbleContent(): void { + this.bubbleEL.textContent = ''; + } + + hideBubble(): void { + this.bubbleEL.style.display = 'none'; + } + + update(view: EditorView): void { + const { state, dispatch } = view; + const { selection, schema, doc, tr } = state; + + if (!schema.marks.link) { + return; + } + + const hasFocus = view.hasFocus(); + const isActive = isMarkActive(state, schema.marks.link); + const linkMarks: Mark[] = getSelectionMarks(state).filter(mark => mark.type === schema.marks.link); + + + // hide for selection and show only for clicks + if (!isActive || linkMarks.length !== 1 || !hasFocus) { + this.hideBubble(); + return; + } + + const { $head: { pos } } = selection; + const [linkItem] = linkMarks; + + this.clearBubbleContent(); + + const removeCB = (e: MouseEvent) => { + e.preventDefault(); + + const $pos = doc.resolve(pos); + const linkStart = pos - $pos.textOffset; + const linkEnd = linkStart + $pos.parent.child($pos.index()).nodeSize; + + tr.removeMark(linkStart, linkEnd); + + dispatch(tr); + view.focus(); + }; + + const el = this.createLinkNode(linkItem, removeCB); + this.bubbleEL.appendChild(el); + + // update dom position + this.setDomPosition(view); + } + + destroy(): void { + this.bubbleEL.remove(); + } +} + +function linkPlugin(): Plugin { + return new Plugin({ + key: new PluginKey('link'), + view(editorView: EditorView): FloatingOptionsView { + return new FloatingOptionsView(editorView); + }, + props: { + handleDOMEvents: { + blur(view): boolean { + view.dispatch(view.state.tr.setMeta('LINK_PLUGIN_EDITOR_BLUR', true)); + return false; + } + } + } + }); +} + +export default linkPlugin; diff --git a/src/lib/prosemirror/plugins/menu/menu.ts b/src/lib/prosemirror/plugins/menu/menu.ts index aef221a2..1646fda2 100644 --- a/src/lib/prosemirror/plugins/menu/menu.ts +++ b/src/lib/prosemirror/plugins/menu/menu.ts @@ -36,7 +36,6 @@ const SELECTED_DROPDOWN_ITEM_CLASSNAME = `${DROPDWON_ITEM_CLASSNAME}--Selected`; const DROPDOWN_ITEMS = new Map(); DROPDOWN_ITEMS.set('heading', ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); - class DropDownView { private dropdownGroup: ToolbarDropdownGroupKeys; private dropdownFields: ToolbarDropdownGroupValues; diff --git a/src/lib/utils/icons/index.ts b/src/lib/utils/icons/index.ts index f8963cfd..e9737e00 100644 --- a/src/lib/utils/icons/index.ts +++ b/src/lib/utils/icons/index.ts @@ -6,6 +6,7 @@ import code from './code'; import orderedList from './ordered_list'; import bulletList from './bullet_list'; import quote from './quote'; +import link from './link'; const DEFAULT_ICON_HEIGHT = 20; const DEFAULT_ICON_WIDTH = 20; @@ -16,7 +17,8 @@ const icons = { code, ordered_list: orderedList, bullet_list: bulletList, - quote + quote, + link }; // Helper function to create menu icons diff --git a/src/lib/utils/icons/link.ts b/src/lib/utils/icons/link.ts new file mode 100644 index 00000000..c12ff978 --- /dev/null +++ b/src/lib/utils/icons/link.ts @@ -0,0 +1,3 @@ +export default ` + +`;