diff --git a/config/webpack.config.js b/config/webpack.config.js index a50808cd..13461f77 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -44,12 +44,13 @@ module.exports = function(env, argv) { ], resolve: { - extensions: [".ts", ".js", ".json", ".html"] + extensions: [".ts", ".js", ".json", ".html", ".tsx"] }, module: { rules: [ { test: /\.ts$/, use:[ "ts-loader", "angular2-template-loader" ] }, + { test: /\.tsx$/, use:[ "ts-loader" ] }, { test: /\.html$/, use:[ "html-loader" ] }, { test: /\.css$/, use: [ "css-to-string-loader", "css-loader" ] } ] diff --git a/editor-extender/context-menu/command-button.tsx b/editor-extender/context-menu/command-button.tsx new file mode 100644 index 00000000..fa34c579 --- /dev/null +++ b/editor-extender/context-menu/command-button.tsx @@ -0,0 +1,28 @@ +import * as React from "react"; + +interface CommandButtonProps { + title?: string; + icon?: string; + command: CommandBase; +} + +export interface CommandBase { + name?: string; + icon?: string; + execute: () => any; + isSeparator?: boolean; +} + +export class CommandButton extends React.Component { + public render() { + return ( + this.props.command.isSeparator ? + () + : + ( {this.props.command.execute(); }}> + {this.props.icon ? () : this.props.title} + ) + ); + } +} diff --git a/editor-extender/context-menu/context-menu-provider.ts b/editor-extender/context-menu/context-menu-provider.ts new file mode 100644 index 00000000..1237e661 --- /dev/null +++ b/editor-extender/context-menu/context-menu-provider.ts @@ -0,0 +1,186 @@ +import { ClassProvider, Injectable } from "@angular/core"; +import { ToolBarItem, EditorConfigProvider, EDITOR_CONFIG_TOKEN } from "progress-sitefinity-adminapp-sdk/app/api/v1"; +import { EditorContextMenu, EditorContextMenuProps } from "./context-menu"; +import { CommandBase } from "./command-button"; +import { StaticReactWrapper } from "./static-react-wrapper"; + +@Injectable() +class ContextMenuProvider implements EditorConfigProvider { + private static editor; + private static reactWrapper: StaticReactWrapper; + + /** + * The method that gets invoked when the editor constructs the toolbar actions. + * + * @param {*} editorHost The Kendo's editor object. + * @returns {ToolBarItem[]} The custom toolbar items that will be added to the Kendo's toolbar. + * @memberof InsertSymbolProvider + */ + getToolBarItems(editorHost: any): ToolBarItem[] { + ContextMenuProvider.editor = editorHost; + return []; + } + + /** + * If you want to remove some toolbar items return their names as strings in the array. Order is insignificant. + * Otherwise return an empty array. + * Example: return [ "embed" ]; + * The above code will remove the embed toolbar item from the editor. + * Documentation where you can find all tools' names: https://docs.telerik.com/kendo-ui/api/javascript/ui/editor/configuration/tools + * + * @returns {string[]} + * @memberof InsertSymbolProvider + */ + getToolBarItemsNamesToRemove(): string[] { + return []; + } + + /** + * This gives access to the Kendo UI Editor configuration object + * that is used to initialize the editor upon creation + * Kendo UI Editor configuration overview documentation -> https://docs.telerik.com/kendo-ui/controls/editors/editor/overview#configuration + * + * @param {*} configuration + * @returns The modified configuration. + * @memberof InsertSymbolProvider + */ + configureEditor(configuration: any) { + const selectCallback = configuration.select; + const cb = () => { + selectCallback(); + ContextMenuProvider.moveMenuTooltip(); + }; + const node = this.injectTooltip(); + ContextMenuProvider.editor[0].parentNode.appendChild(node); + configuration.select = cb; + return configuration; + } + + /** + * Injects the holder and the menu tooltip into the editor DOM. + */ + private injectTooltip() { + const wrapper = new StaticReactWrapper(); + wrapper.wrappedComponent = EditorContextMenu; + ContextMenuProvider.reactWrapper = wrapper; + + const node = this.generateDummyHolder(); + wrapper.rootDom = node; + wrapper.update( {position: {x: 0, y: 0}, commands: this.defineCommands(), isVisible: false} ); + return node; + } + + /** + * Caclulates the current selection position. + */ + private static getSelectionPosition() { + let x = 0; + let y = 0; + const sel = window.getSelection(); + if (sel.rangeCount && sel.type === "Range") { + const range = sel.getRangeAt(0).cloneRange(); + if (range.getClientRects()) { + range.collapse(true); + const rect = range.getClientRects()[0]; + if (rect) { + y = rect.top; + x = rect.left + rect.width / 2; + } + } + } + + return { x, y }; + } + + /** + * Calculates the position of the menu and sends the values to the component props. + */ + private static moveMenuTooltip() { + const position = {x: 0, y: 0}; + const pos = ContextMenuProvider.getSelectionPosition(); + const editorpos = ContextMenuProvider.editor[0].getBoundingClientRect(); + let isVisible = true; + if (!pos.x || !pos.y) { + isVisible = false; + } + position.x = pos.x - editorpos.x; + position.y = pos.y - editorpos.y; + ContextMenuProvider.reactWrapper.update({ position, isVisible }); + } + + /** + * Generates the root node that would host the context menu. + */ + private generateDummyHolder() { + const node = document.createElement("div"); + node.style.position = "absolute"; + node.style.top = "0px"; + node.style.left = "0px"; + node.style.height = "0%"; + node.style.width = "0%"; + node.style.pointerEvents = "box-none"; + return node; + } + + /** + * Creates the commands for the context menu. + */ + private defineCommands(): CommandBase[] { + const executable = (action) => { + ContextMenuProvider.editor.getKendoEditor().focus(); + ContextMenuProvider.editor.getKendoEditor().exec(action); + }; + + const bold = { + icon: "bold", + execute: () => { + executable("bold"); + } + }; + + const italic = { + icon: "italic", + execute: () => { + executable("italic"); + } + }; + + const underline = { + icon: "underline", + execute: () => { + executable("underline"); + } + }; + + const sup = { + icon: "superscript", + execute: () => { + executable("superscript"); + } + }; + + const sub = { + icon: "subscript", + execute: () => { + executable("subscript"); + } + }; + + const clean = { + name: "clear", + execute: () => { + executable("cleanFormatting"); + } + }; + + const separator = {name: null, execute: null, isSeparator: true}; + + return [bold, italic, underline, separator, sup, sub, separator, clean]; + } +} + +export const CONTEXT_MENU_PROVIDER: ClassProvider = { + multi: true, + provide: EDITOR_CONFIG_TOKEN, + useClass: ContextMenuProvider +}; diff --git a/editor-extender/context-menu/context-menu.tsx b/editor-extender/context-menu/context-menu.tsx new file mode 100644 index 00000000..46ee3b5f --- /dev/null +++ b/editor-extender/context-menu/context-menu.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { CommandBase, CommandButton } from "./command-button"; + +/** + * Represents the props for the EditorContextMenu + */ +export interface EditorContextMenuProps { + position: {x: number, y: number}; + commands: CommandBase[]; + isVisible: boolean; +} + +/** + * Represents the state of the EditorContextMenu + */ +export interface EditorContextMenuState { + positionOffset: {x: number, y: number}; +} + +export class EditorContextMenu extends React.Component { + constructor(p, s) { + super(p, s); + this.state = {positionOffset: {x: 0, y: 0}}; + } + + public render() { + return ( this.props.isVisible && +
+
{ this.calculatePosition(ref); }}> +
+ { + this.props.commands.map((btn, i) => { + return (); + }) + } +
+
+
+ ); + } + + private calculatePosition(ref) { + if (ref && this.state.positionOffset.x === 0) { + const boundingRectangle = ref.getBoundingClientRect(); + this.setState( { positionOffset: { + x: boundingRectangle.width / 2, + y: boundingRectangle.height + }}); + } + } + + private generateStyle(): React.CSSProperties { + const pos = this.props.position || {x: 0, y: 0}; + const offset = this.state.positionOffset; + return { + top: pos.y - 5, + left: pos.x - offset.x, + position: "relative", + display: "flex", + flexDirection: "row" + }; + } +} diff --git a/editor-extender/context-menu/static-react-wrapper.ts b/editor-extender/context-menu/static-react-wrapper.ts new file mode 100644 index 00000000..d47f5754 --- /dev/null +++ b/editor-extender/context-menu/static-react-wrapper.ts @@ -0,0 +1,37 @@ +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +/** + * Provides a class that wraps a React component to be inserted into the DOM and updated externally if needed. + * React handles internal updates and function calls independently updating only what is changed and needs to be rerendered. + */ +export class StaticReactWrapper { + public wrappedComponent; + public rootDom; + private props: T; + + /** + * Performs an update to the wrapped react component. + * @param newProps partial or full set of the component props that need to be updated. + */ + public update(newProps: any) { + const mergedProps = Object.assign({}, this.props, newProps); + this.props = mergedProps; + this.render(); + } + + /** + * Renders the wrapped React component at the chosen node. + */ + public render() { + ReactDOM.render(React.createElement(this.wrappedComponent, this.getProps()), this.getRootDomNode()); + } + + private getProps() { + return this.props; + } + + private getRootDomNode() { + return this.rootDom; + } +} diff --git a/editor-extender/index.ts b/editor-extender/index.ts index b95878dd..031480c3 100644 --- a/editor-extender/index.ts +++ b/editor-extender/index.ts @@ -6,6 +6,7 @@ import { SWITCH_TEXT_DIRECTION_PROVIDER } from "./switch-text-direction/switch-t import { INSERT_SYMBOL_PROVIDER } from "./insert-symbol/insert-symbol.provider"; import { EDIT_MENU_SPELL_CHECK_PROVIDER } from "./spell-check/edit-menu-spell-check-provider"; import { EDITOR_SPELL_CHECK_PROVIDER } from "./spell-check/editor-spell-check-provider"; +import { CONTEXT_MENU_PROVIDER } from "./context-menu/context-menu-provider"; /** * The toolbar extender module. @@ -17,6 +18,7 @@ import { EDITOR_SPELL_CHECK_PROVIDER } from "./spell-check/editor-spell-check-pr SWITCH_TEXT_DIRECTION_PROVIDER, INSERT_SYMBOL_PROVIDER, EDITOR_SPELL_CHECK_PROVIDER, + CONTEXT_MENU_PROVIDER, EDIT_MENU_SPELL_CHECK_PROVIDER ], imports: [ diff --git a/package-lock.json b/package-lock.json index 86abe281..28e382ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -163,6 +163,22 @@ "integrity": "sha512-GWWbvt+z9G5otRBW8rssOFgRY87J9N/qbhqfjMZ+gUuL6zoL+Hm6gP/8qQBG4jjimqdaNLCehcVapZ/Fs2WjCQ==", "dev": true }, + "@types/prop-types": { + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.1.tgz", + "integrity": "sha512-CFzn9idOEpHrgdw8JsoTkaDDyRWk1jrzIV8djzcgpq0y9tG4B4lFT+Nxh52DVpDXV+n4+NPNv7M1Dj5uMp6XFg==", + "dev": true + }, + "@types/react": { + "version": "16.8.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-16.8.17.tgz", + "integrity": "sha512-pln3mgc6VfkNg92WXODul/ONo140huK9OMsx62GlBlZ2lvjNK86PQJhYMPLO1i66aF5O9OPyZefogvNltBIszA==", + "dev": true, + "requires": { + "@types/prop-types": "*", + "csstype": "^2.2.0" + } + }, "@webassemblyjs/ast": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.7.11.tgz", @@ -1481,6 +1497,12 @@ "integrity": "sha1-yBSQPkViM3GgR3tAEJqq++6t27Q=", "dev": true }, + "csstype": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.4.tgz", + "integrity": "sha512-lAJUJP3M6HxFXbqtGRc0iZrdyeN+WzOWeY0q/VnFzI+kqVrYIzC7bWlKqCW7oCIdzoPkvfp82EVvrTlQ8zsWQg==", + "dev": true + }, "cyclist": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/cyclist/-/cyclist-0.2.2.tgz", @@ -3664,6 +3686,15 @@ "integrity": "sha1-4PyVEztu8nbNyIh82vJKpvFW+Po=", "dev": true }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, "lower-case": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-1.1.4.tgz", @@ -4516,6 +4547,17 @@ "integrity": "sha1-mEcocL8igTL8vdhoEputEsPAKeM=", "dev": true }, + "prop-types": { + "version": "15.7.2", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz", + "integrity": "sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==", + "dev": true, + "requires": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.8.1" + } + }, "proxy-addr": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.4.tgz", @@ -4640,6 +4682,36 @@ "unpipe": "1.0.0" } }, + "react": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react/-/react-16.8.6.tgz", + "integrity": "sha512-pC0uMkhLaHm11ZSJULfOBqV4tIZkx87ZLvbbQYunNixAAvjnC+snJCg0XQXn9VIsttVsbZP/H/ewzgsd5fxKXw==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-dom": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.8.6.tgz", + "integrity": "sha512-1nL7PIq9LTL3fthPqwkvr2zY7phIPjYrT0jp4HjyEQrEROnw4dG41VVwi/wfoCneoleqrNX7iAD+pXebJZwrwA==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1", + "prop-types": "^15.6.2", + "scheduler": "^0.13.6" + } + }, + "react-is": { + "version": "16.8.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.8.6.tgz", + "integrity": "sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==", + "dev": true + }, "readable-stream": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", @@ -4882,6 +4954,16 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "scheduler": { + "version": "0.13.6", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.13.6.tgz", + "integrity": "sha512-IWnObHt413ucAYKsD9J1QShUKkbKLQQHdxRyw73sw4FN26iWr3DY/H34xGPe4nmL1DwXyWmSWmMrA9TfQbE/XQ==", + "dev": true, + "requires": { + "loose-envify": "^1.1.0", + "object-assign": "^4.1.1" + } + }, "schema-utils": { "version": "0.4.5", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-0.4.5.tgz", @@ -6009,9 +6091,9 @@ "dev": true }, "typescript": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-2.7.1.tgz", - "integrity": "sha512-bqB1yS6o9TNA9ZC/MJxM0FZzPnZdtHj0xWK/IZ5khzVqdpGul/R/EIiHRgFXlwTD7PSIaYVnGKq1QgMCu2mnqw==", + "version": "3.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz", + "integrity": "sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==", "dev": true }, "uglify-js": { diff --git a/package.json b/package.json index 9f1dac01..f911bec7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "start": "webpack-dev-server --config config/webpack.config.js --env.dev", "lint": "tslint --project ./tsconfig.json", "lint:fix": "tslint --fix --project ./tsconfig.json" - }, "keywords": [ "extensions", @@ -29,6 +28,7 @@ "@angular/platform-browser-dynamic": "7.0.0", "@angular/router": "7.0.0", "@types/node": "10.12.24", + "@types/react": "16.8.17", "angular2-template-loader": "0.6.2", "circular-dependency-plugin": "5.0.2", "awesome-typescript-loader": "3.1.3", @@ -39,12 +39,14 @@ "html-loader": "0.5.5", "json-loader": "0.5.7", "progress-sitefinity-adminapp-sdk": "1.0.2", + "react": "16.8.6", + "react-dom": "16.8.6", "rxjs": "6.2.2", "style-loader": "0.20.3", "tslib": "1.7.1", "tslint": "5.9.1", "tslint-eslint-rules": "4.1.1", - "typescript": "2.7.1", + "typescript": "3.4.5", "uglifyjs-webpack-plugin": "1.2.5", "webpack": "4.29.3", "webpack-dev-server": "3.1.14", diff --git a/tsconfig.json b/tsconfig.json index 32b15748..6f86391d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "sourceMap": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, + "jsx": "react", "removeComments": false, "noImplicitAny": false, "noEmitHelpers": true,