Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Editor context menu with React #112

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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" ] }
]
Expand Down
28 changes: 28 additions & 0 deletions editor-extender/context-menu/command-button.tsx
Original file line number Diff line number Diff line change
@@ -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<CommandButtonProps> {
public render() {
return (
this.props.command.isSeparator ?
(<span className="sf-notification__tool-separator"></span>)
:
(<span className="sf-notification__tool-button"
onClick={() => {this.props.command.execute(); }}>
{this.props.icon ? (<i className={`sf-icon fa fa-${this.props.icon} -color-inherit -size-xs`}></i>) : this.props.title}
</span>)
);
}
}
186 changes: 186 additions & 0 deletions editor-extender/context-menu/context-menu-provider.ts
Original file line number Diff line number Diff line change
@@ -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<EditorContextMenuProps, EditorContextMenu>;

/**
* 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<EditorContextMenuProps, EditorContextMenu>();
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
};
63 changes: 63 additions & 0 deletions editor-extender/context-menu/context-menu.tsx
Original file line number Diff line number Diff line change
@@ -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<EditorContextMenuProps, EditorContextMenuState> {
constructor(p, s) {
super(p, s);
this.state = {positionOffset: {x: 0, y: 0}};
}

public render() {
return ( this.props.isVisible &&
<div style={this.generateStyle()} className="sf-tooltip__content -toolset -up">
<div className="sf-notification -toolset -black -up" ref={(ref) => { this.calculatePosition(ref); }}>
<div className="sf-notification__content">
{
this.props.commands.map((btn, i) => {
return (<CommandButton key={`context-menu-${i}`} icon = {btn.icon} title={btn.name} command={btn}></CommandButton>);
})
}
</div>
</div>
</div>
);
}

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"
};
}
}
37 changes: 37 additions & 0 deletions editor-extender/context-menu/static-react-wrapper.ts
Original file line number Diff line number Diff line change
@@ -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<T, ComponentClass extends React.Component> {
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;
}
}
2 changes: 2 additions & 0 deletions editor-extender/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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: [
Expand Down
Loading