Skip to content

Commit

Permalink
feat(plugins): add support for custom menu item
Browse files Browse the repository at this point in the history
  • Loading branch information
sibiraj-s committed May 31, 2020
1 parent 481895b commit 1737369
Show file tree
Hide file tree
Showing 19 changed files with 408 additions and 54 deletions.
19 changes: 19 additions & 0 deletions demo/src/app/app.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,22 @@
}
}
}

.CustomMenuItem {
padding: 0 0.3rem;
border-radius: 2px;

&.NgxEditor-MenuItem__Active {
background-color: #e8f0fe;
color: #1a73e8;
}
}

.CodeMirror {
border: 1px solid #eee;
height: auto;

pre {
white-space: pre !important;
}
}
5 changes: 3 additions & 2 deletions demo/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Component } from '@angular/core';
import { Component, ViewEncapsulation } from '@angular/core';
import { environment } from '../environments/environment';

@Component({
selector: 'app-root',
templateUrl: 'app.component.html',
styleUrls: ['app.component.scss']
styleUrls: ['app.component.scss'],
encapsulation: ViewEncapsulation.None
})

export class AppComponent {
Expand Down
8 changes: 6 additions & 2 deletions demo/src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { NgxEditorModule } from 'ngx-editor';

import { getPlugins } from './plugin';
import schema from './schema';
import plugins from './plugins';
import nodeViews from './nodeviews';

@NgModule({
declarations: [
Expand All @@ -17,7 +19,9 @@ import { getPlugins } from './plugin';
BrowserModule,
FormsModule,
NgxEditorModule.forRoot({
plugins: getPlugins()
schema,
plugins,
nodeViews
}),
],
bootstrap: [AppComponent]
Expand Down
164 changes: 164 additions & 0 deletions demo/src/app/nodeviews/CodeMirror.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { exitCode } from 'prosemirror-commands';
import { undo, redo } from 'prosemirror-history';
import { TextSelection, Selection } from 'prosemirror-state';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { EditorView } from 'prosemirror-view';

import CodeMirror from 'codemirror';
import 'codemirror/mode/javascript/javascript';

import schema from '../schema';

function computeChange(oldVal: string, newVal: string) {
if (oldVal === newVal) { return null; }
let start = 0;
let oldEnd = oldVal.length;
let newEnd = newVal.length;
while (start < oldEnd && oldVal.charCodeAt(start) === newVal.charCodeAt(start)) {
++start;
}
while (oldEnd > start && newEnd > start && oldVal.charCodeAt(oldEnd - 1) === newVal.charCodeAt(newEnd - 1)) {
oldEnd--; newEnd--;
}
return { from: start, to: oldEnd, text: newVal.slice(start, newEnd) };
}

type GetPos = () => number;

class CodeMirrorView {
node: ProsemirrorNode;
getPos: GetPos;
incomingChanges: boolean;

cm: CodeMirror;

view: EditorView;
dom: HTMLElement;
updating: boolean;

constructor(node: ProsemirrorNode, view: EditorView, getPos: GetPos) {
// Store for later
this.node = node;
this.view = view;
this.getPos = getPos;
this.incomingChanges = false;

// Create a CodeMirror instance
this.cm = new CodeMirror(null, {
value: this.node.textContent,
lineNumbers: true,
extraKeys: this.codeMirrorKeymap()
});

// The editor's outer node is our DOM representation
this.dom = this.cm.getWrapperElement();
// CodeMirror needs to be in the DOM to properly initialize, so
// schedule it to update itself
setTimeout(() => this.cm.refresh(), 20);

// This flag is used to avoid an update loop between the outer and
// inner editor
this.updating = false;
// Track whether changes are have been made but not yet propagated
this.cm.on('beforeChange', () => this.incomingChanges = true);
// Propagate updates from the code editor to ProseMirror
this.cm.on('cursorActivity', () => {
if (!this.updating && !this.incomingChanges) { this.forwardSelection(); }
});

this.cm.on('changes', () => {
if (!this.updating) {
this.valueChanged();
this.forwardSelection();
}
this.incomingChanges = false;
});

this.cm.on('focus', () => this.forwardSelection());
}

forwardSelection() {
if (!this.cm.hasFocus()) {
return;
}

const state = this.view.state;
const selection = this.asProseMirrorSelection(state.doc);

if (!selection.eq(state.selection)) {
this.view.dispatch(state.tr.setSelection(selection));
}
}

asProseMirrorSelection(doc: ProsemirrorNode) {
const offset = this.getPos() + 1;
const anchor = this.cm.indexFromPos(this.cm.getCursor('anchor')) + offset;
const head = this.cm.indexFromPos(this.cm.getCursor('head')) + offset;
return TextSelection.create(doc, anchor, head);
}

setSelection(anchor: number, head: number) {
this.cm.focus();
this.updating = true;
this.cm.setSelection(this.cm.posFromIndex(anchor), this.cm.posFromIndex(head));
this.updating = false;
}

valueChanged() {
const change = computeChange(this.node.textContent, this.cm.getValue());
if (change) {
const start = this.getPos() + 1;
const tr = this.view.state.tr.replaceWith(
start + change.from, start + change.to,
change.text ? schema.text(change.text) : null);
this.view.dispatch(tr);
}
}
codeMirrorKeymap() {
const view = this.view;
const mod = /Mac/.test(navigator.platform) ? 'Cmd' : 'Ctrl';
return CodeMirror.normalizeKeyMap({
Up: () => this.maybeEscape('line', -1),
Left: () => this.maybeEscape('char', -1),
Down: () => this.maybeEscape('line', 1),
Right: () => this.maybeEscape('char', 1),
[`${mod}-Z`]: () => undo(view.state, view.dispatch),
[`Shift-${mod}-Z`]: () => redo(view.state, view.dispatch),
[`${mod}-Y`]: () => redo(view.state, view.dispatch),
'Ctrl-Enter': () => {
if (exitCode(view.state, view.dispatch)) { view.focus(); }
}
});
}

maybeEscape(unit: string, dir: number) {
const pos = this.cm.getCursor();
if (this.cm.somethingSelected() ||
pos.line !== (dir < 0 ? this.cm.firstLine() : this.cm.lastLine()) ||
(unit === 'char' &&
pos.ch !== (dir < 0 ? 0 : this.cm.getLine(pos.line).length))) {
return CodeMirror.Pass;
}

this.view.focus();
const targetPos = this.getPos() + (dir < 0 ? 0 : this.node.nodeSize);
const selection = Selection.near(this.view.state.doc.resolve(targetPos), dir);
this.view.dispatch(this.view.state.tr.setSelection(selection).scrollIntoView());
this.view.focus();
}

update(node: ProsemirrorNode) {
if (node.type !== this.node.type) { return false; }
this.node = node;
const change = computeChange(this.cm.getValue(), node.textContent);
if (change) {
this.updating = true;
this.cm.replaceRange(change.text, this.cm.posFromIndex(change.from),
this.cm.posFromIndex(change.to));
this.updating = false;
}
return true;
}
}

export default CodeMirrorView;
11 changes: 11 additions & 0 deletions demo/src/app/nodeviews/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import CodeBlockView from './CodeMirror';
import { Node as ProsemirrorNode } from 'prosemirror-model';
import { EditorView } from 'prosemirror-view';

const nodeViews = {
code_block: (node: ProsemirrorNode, view: EditorView, getPos: () => number) => {
return new CodeBlockView(node, view, getPos);
}
};

export default nodeViews;
9 changes: 7 additions & 2 deletions demo/src/app/plugin.ts → demo/src/app/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { keymap } from 'prosemirror-keymap';
import { toggleMark, baseKeymap } from 'prosemirror-commands';
import { Plugin } from 'prosemirror-state';

import codemirrorMenu from './menu/codemirror';

const isMacOs = /Mac/.test(navigator.platform);

export type KeyMap = { [key: string]: any };
Expand Down Expand Up @@ -35,7 +37,7 @@ const getListKeyMap = (): KeyMap => {
return listMap;
};

export const getPlugins = (): Plugin[] => {
const getPlugins = (): Plugin[] => {
const historyKeyMap = getHistoryKeyMap();
const listKeyMap = getListKeyMap();

Expand All @@ -54,7 +56,8 @@ export const getPlugins = (): Plugin[] => {
['bold', 'italic'],
['code'],
['ordered_list', 'bullet_list'],
[{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }]
[{ heading: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] }],
[codemirrorMenu]
],
labels: {
bold: 'Bold',
Expand All @@ -70,3 +73,5 @@ export const getPlugins = (): Plugin[] => {

return plugins;
};

export default getPlugins();
48 changes: 48 additions & 0 deletions demo/src/app/plugins/menu/codemirror.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { EditorState } from 'prosemirror-state';
import { isNodeActive, toggleBlockType, ToolbarCustomMenuItem } from 'ngx-editor';

import schema from '../../schema';

const codeMirror: ToolbarCustomMenuItem = (editorView) => {
const dom: HTMLElement = document.createElement('div');
dom.innerHTML = 'CodeMirror';

dom.classList.add('NgxEditor-MenuItem');
dom.classList.add('CustomMenuItem');

const type = schema.nodes.code_block;

let command;

dom.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();

// don't execute if not left click
if (e.buttons !== 1) {
return;
}

command = toggleBlockType(type, schema.nodes.paragraph);
command(editorView.state, editorView.dispatch);
});


const update = (state: EditorState): void => {
const isActive = isNodeActive(state, type);
let canExecute = true;

if (command) {
canExecute = command(state, null);
}

dom.classList.toggle(`NgxEditor-MenuItem__Active`, isActive);
dom.classList.toggle(`disabled`, !canExecute);
};

return {
dom,
update
};
};

export default codeMirror;
38 changes: 38 additions & 0 deletions demo/src/app/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { nodes as basicNodes, marks as basicMarks } from 'ngx-editor';
import { Schema, Node as ProsemirrorNode, NodeSpec } from 'prosemirror-model';

const codeBlock: NodeSpec = {
group: 'block',
attrs: {
text: { default: '' },
language: { default: 'text/javascript' }
},
parseDOM: [{
tag: 'pre',
getAttrs: (dom: HTMLElement) => {
return {
text: dom.textContent,
language: dom.getAttribute('data-language') || 'text/plain'
};
}
}
],
toDOM(node: ProsemirrorNode) {
return ['pre', { 'data-language': node.attrs.language }, node.attrs.text];
}
};

const nodes = Object.assign(
{},
basicNodes,
{
code_mirror: codeBlock
}
);

const schema = new Schema({
nodes,
marks: basicMarks
});

export default schema;
1 change: 1 addition & 0 deletions demo/src/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "~codemirror/lib/codemirror.css";
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@types/prosemirror-view": "^1.11.4",
"chalk": "^4.0.0",
"codelyzer": "^5.1.2",
"codemirror": "^5.54.0",
"eslint": "^6.8.0",
"eslint-config-airbnb-base": "^14.1.0",
"eslint-plugin-import": "^2.20.2",
Expand Down
Loading

0 comments on commit 1737369

Please sign in to comment.