Browse Source

Initial commit

add-plugins
Nithin David 4 years ago
parent
commit
62415b7b42
  1. 48
      src/Field.js
  2. 27
      src/Placeholder.js
  3. 18
      src/SearchField.js
  4. 14
      src/TextField.js
  5. 77
      src/index.js
  6. 70
      src/inputrules.js
  7. 120
      src/keymap.js
  8. 121
      src/menu.js
  9. 114
      src/prompt.js
  10. 251
      src/woot-editor.css

48
src/Field.js

@ -0,0 +1,48 @@
/* eslint-disable class-methods-use-this */
// ::- The type of field that `FieldPrompt` expects to be passed to it.
export class Field {
// :: (Object)
// Create a field with the given options. Options support by all
// field types are:
//
// **`value`**`: ?any`
// : The starting value for the field.
//
// **`label`**`: string`
// : The label for the field.
//
// **`required`**`: ?bool`
// : Whether the field is required.
//
// **`validate`**`: ?(any) → ?string`
// : A function to validate the given value. Should return an
// error message if it is not valid.
constructor(options) {
this.options = options;
}
// render:: (state: EditorState, props: Object) → dom.Node
// Render the field to the DOM. Should be implemented by all subclasses.
// :: (dom.Node) → any
// Read the field's value from its DOM node.
read(dom) {
return dom.value;
}
// :: (any) → ?string
// A field-type-specific validation function.
validateType() {}
validate(value) {
if (!value && this.options.required) return 'Required field';
return (
this.validateType(value) ||
(this.options.validate && this.options.validate(value))
);
}
clean(value) {
return this.options.clean ? this.options.clean(value) : value;
}
}

27
src/Placeholder.js

@ -0,0 +1,27 @@
import { Plugin } from 'prosemirror-state';
import { Decoration, DecorationSet } from 'prosemirror-view';
export default (placeholderText = '') => {
return new Plugin({
props: {
decorations: state => {
const decorations = [];
const decorate = (node, pos) => {
if (node.type.isBlock && node.childCount === 0) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: 'empty-node',
'data-placeholder': placeholderText,
})
);
}
};
state.doc.descendants(decorate);
return DecorationSet.create(state.doc, decorations);
},
},
});
};

18
src/SearchField.js

@ -0,0 +1,18 @@
import Field from './Field';
// ::- A field class for dropdown fields based on a plain `<select>`
// tag. Expects an option `options`, which should be an array of
// `{value: string, label: string}` objects, or a function taking a
// `ProseMirror` instance and returning such an array.
export class SelectField extends Field {
render() {
let select = document.createElement('select');
this.options.options.forEach(o => {
let opt = select.appendChild(document.createElement('option'));
opt.value = o.value;
opt.selected = o.value === this.options.value;
opt.label = o.label;
});
return select;
}
}

14
src/TextField.js

@ -0,0 +1,14 @@
import { Field } from './Field';
// ::- A field class for single-line text fields.
export class TextField extends Field {
render() {
let input = document.createElement('input');
input.type = 'text';
input.placeholder = this.options.label;
input.className = this.options.class;
input.value = this.options.value || '';
input.autocomplete = 'off';
return input;
}
}

77
src/index.js

@ -0,0 +1,77 @@
import { keymap } from 'prosemirror-keymap';
import { history } from 'prosemirror-history';
import { baseKeymap } from 'prosemirror-commands';
import { Plugin } from 'prosemirror-state';
import { dropCursor } from 'prosemirror-dropcursor';
import { gapCursor } from 'prosemirror-gapcursor';
import { menuBar } from 'prosemirror-menu';
import { buildMenuItems } from './menu';
import { buildKeymap } from './keymap';
import { buildInputRules } from './inputrules';
import Placeholder from './Placeholder';
export { buildMenuItems, buildKeymap, buildInputRules };
// !! This module exports helper functions for deriving a set of basic
// menu items, input rules, or key bindings from a schema. These
// values need to know about the schema for two reasons—they need
// access to specific instances of node and mark types, and they need
// to know which of the node and mark types that they know about are
// actually present in the schema.
//
// The `exampleSetup` plugin ties these together into a plugin that
// will automatically enable this basic functionality in an editor.
// :: (Object) → [Plugin]
// A convenience plugin that bundles together a simple menu with basic
// key bindings, input rules, and styling for the example schema.
// Probably only useful for quickly setting up a passable
// editor—you'll need more control over your settings in most
// real-world situations.
//
// options::- The following options are recognized:
//
// schema:: Schema
// The schema to generate key bindings and menu items for.
//
// mapKeys:: ?Object
// Can be used to [adjust](#example-setup.buildKeymap) the key bindings created.
//
// menuBar:: ?bool
// Set to false to disable the menu bar.
//
// history:: ?bool
// Set to false to disable the history plugin.
//
// floatingMenu:: ?bool
// Set to false to make the menu bar non-floating.
//
// menuContent:: [[MenuItem]]
// Can be used to override the menu content.
export function wootWriterSetup(options) {
let plugins = [
buildInputRules(options.schema),
keymap(buildKeymap(options.schema, options.mapKeys)),
keymap(baseKeymap),
dropCursor(),
gapCursor(),
Placeholder(options.placeholder),
];
if (options.menuBar !== false)
plugins.push(
menuBar({
floating: options.floatingMenu !== false,
content: options.menuContent || buildMenuItems(options.schema).fullMenu,
})
);
if (options.history !== false) plugins.push(history());
return plugins.concat(
new Plugin({
props: {
attributes: { class: 'ProseMirror-woot-style' },
},
})
);
}

70
src/inputrules.js

@ -0,0 +1,70 @@
/* eslint-disable no-cond-assign */
import {
inputRules,
wrappingInputRule,
textblockTypeInputRule,
smartQuotes,
emDash,
ellipsis,
} from 'prosemirror-inputrules';
// : (NodeType) → InputRule
// Given a blockquote node type, returns an input rule that turns `"> "`
// at the start of a textblock into a blockquote.
export function blockQuoteRule(nodeType) {
return wrappingInputRule(/^\s*>\s$/, nodeType);
}
// : (NodeType) → InputRule
// Given a list node type, returns an input rule that turns a number
// followed by a dot at the start of a textblock into an ordered list.
export function orderedListRule(nodeType) {
return wrappingInputRule(
/^(\d+)\.\s$/,
nodeType,
match => ({ order: +match[1] }),
(match, node) => node.childCount + node.attrs.order === +match[1]
);
}
// : (NodeType) → InputRule
// Given a list node type, returns an input rule that turns a bullet
// (dash, plush, or asterisk) at the start of a textblock into a
// bullet list.
export function bulletListRule(nodeType) {
return wrappingInputRule(/^\s*([-+*])\s$/, nodeType);
}
// : (NodeType) → InputRule
// Given a code block node type, returns an input rule that turns a
// textblock starting with three backticks into a code block.
export function codeBlockRule(nodeType) {
return textblockTypeInputRule(/^```$/, nodeType);
}
// : (NodeType, number) → InputRule
// Given a node type and a maximum level, creates an input rule that
// turns up to that number of `#` characters followed by a space at
// the start of a textblock into a heading whose level corresponds to
// the number of `#` signs.
export function headingRule(nodeType, maxLevel) {
return textblockTypeInputRule(
new RegExp('^(#{1,' + maxLevel + '})\\s$'),
nodeType,
match => ({ level: match[1].length })
);
}
// : (Schema) → Plugin
// A set of input rules for creating the basic block quotes, lists,
// code blocks, and heading.
export function buildInputRules(schema) {
let rules = smartQuotes.concat(ellipsis, emDash);
let type;
if ((type = schema.nodes.blockquote)) rules.push(blockQuoteRule(type));
if ((type = schema.nodes.ordered_list)) rules.push(orderedListRule(type));
if ((type = schema.nodes.bullet_list)) rules.push(bulletListRule(type));
if ((type = schema.nodes.code_block)) rules.push(codeBlockRule(type));
if ((type = schema.nodes.heading)) rules.push(headingRule(type, 6));
return inputRules({ rules });
}

120
src/keymap.js

@ -0,0 +1,120 @@
/* eslint-disable no-plusplus */
/* eslint-disable no-cond-assign */
import {
wrapIn,
setBlockType,
chainCommands,
toggleMark,
exitCode,
joinUp,
joinDown,
lift,
selectParentNode,
} from 'prosemirror-commands';
import {
wrapInList,
splitListItem,
liftListItem,
sinkListItem,
} from 'prosemirror-schema-list';
import { undo, redo } from 'prosemirror-history';
import { undoInputRule } from 'prosemirror-inputrules';
const mac =
typeof navigator !== 'undefined' ? /Mac/.test(navigator.platform) : false;
// :: (Schema, ?Object) → Object
// Inspect the given schema looking for marks and nodes from the
// basic schema, and if found, add key bindings related to them.
// This will add:
//
// * **Mod-b** for toggling [strong](#schema-basic.StrongMark)
// * **Mod-i** for toggling [emphasis](#schema-basic.EmMark)
// * **Mod-`** for toggling [code font](#schema-basic.CodeMark)
// * **Ctrl-Shift-0** for making the current textblock a paragraph
// * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current
// textblock a heading of the corresponding level
// * **Ctrl-Shift-Backslash** to make the current textblock a code block
// * **Ctrl-Shift-8** to wrap the selection in an ordered list
// * **Ctrl-Shift-9** to wrap the selection in a bullet list
// * **Ctrl->** to wrap the selection in a block quote
// * **Enter** to split a non-empty textblock in a list item while at
// the same time splitting the list item
// * **Mod-Enter** to insert a hard break
// * **Mod-_** to insert a horizontal rule
// * **Backspace** to undo an input rule
// * **Alt-ArrowUp** to `joinUp`
// * **Alt-ArrowDown** to `joinDown`
// * **Mod-BracketLeft** to `lift`
// * **Escape** to `selectParentNode`
//
// You can suppress or map these bindings by passing a `mapKeys`
// argument, which maps key names (say `"Mod-B"` to either `false`, to
// remove the binding, or a new key name string.
export function buildKeymap(schema, mapKeys) {
let keys = {};
let type;
function bind(key, cmd) {
if (mapKeys) {
let mapped = mapKeys[key];
if (mapped === false) return;
if (mapped) key = mapped;
}
keys[key] = cmd;
}
bind('Mod-z', undo);
bind('Shift-Mod-z', redo);
bind('Backspace', undoInputRule);
if (!mac) bind('Mod-y', redo);
bind('Alt-ArrowUp', joinUp);
bind('Alt-ArrowDown', joinDown);
bind('Mod-BracketLeft', lift);
bind('Escape', selectParentNode);
if ((type = schema.marks.strong)) {
bind('Mod-b', toggleMark(type));
bind('Mod-B', toggleMark(type));
}
if ((type = schema.marks.em)) {
bind('Mod-i', toggleMark(type));
bind('Mod-I', toggleMark(type));
}
if ((type = schema.marks.code)) bind('Mod-`', toggleMark(type));
if ((type = schema.nodes.bullet_list)) bind('Shift-Ctrl-8', wrapInList(type));
if ((type = schema.nodes.ordered_list))
bind('Shift-Ctrl-9', wrapInList(type));
if ((type = schema.nodes.blockquote)) bind('Ctrl->', wrapIn(type));
if ((type = schema.nodes.hard_break)) {
let br = type;
let cmd = chainCommands(exitCode, (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView());
return true;
});
bind('Mod-Enter', cmd);
bind('Shift-Enter', cmd);
if (mac) bind('Ctrl-Enter', cmd);
}
if ((type = schema.nodes.list_item)) {
bind('Enter', splitListItem(type));
bind('Mod-[', liftListItem(type));
bind('Mod-]', sinkListItem(type));
}
if ((type = schema.nodes.paragraph)) bind('Shift-Ctrl-0', setBlockType(type));
if ((type = schema.nodes.code_block))
bind('Shift-Ctrl-\\', setBlockType(type));
if ((type = schema.nodes.heading))
for (let i = 1; i <= 6; i++)
bind('Shift-Ctrl-' + i, setBlockType(type, { level: i }));
if ((type = schema.nodes.horizontal_rule)) {
let hr = type;
bind('Mod-_', (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView());
return true;
});
}
return keys;
}

121
src/menu.js

@ -0,0 +1,121 @@
/* eslint-disable no-cond-assign */
/* eslint-disable no-plusplus */
import { undoItem, redoItem, icons, MenuItem } from 'prosemirror-menu';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { openPrompt } from './prompt';
import { TextField } from './TextField';
// Helpers to create specific types of items
function cmdItem(cmd, options) {
let passedOptions = {
label: options.title,
run: cmd,
};
Object.keys(options).reduce((acc, optionKey) => {
acc[optionKey] = options[optionKey];
return acc;
}, passedOptions);
if ((!options.enable || options.enable === true) && !options.select)
passedOptions[options.enable ? 'enable' : 'select'] = state => cmd(state);
return new MenuItem(passedOptions);
}
function markActive(state, type) {
let { from, $from, to, empty } = state.selection;
if (empty) return type.isInSet(state.storedMarks || $from.marks());
return state.doc.rangeHasMark(from, to, type);
}
function markItem(markType, options) {
let passedOptions = {
active(state) {
return markActive(state, markType);
},
enable: true,
};
Object.keys(options).reduce((acc, optionKey) => {
acc[optionKey] = options[optionKey];
return acc;
}, passedOptions);
return cmdItem(toggleMark(markType), passedOptions);
}
function linkItem(markType) {
return new MenuItem({
title: 'Add or remove link',
icon: icons.link,
active(state) {
return markActive(state, markType);
},
enable(state) {
return !state.selection.empty;
},
run(state, dispatch, view) {
if (markActive(state, markType)) {
toggleMark(markType)(state, dispatch);
return true;
}
openPrompt({
title: 'Create a link',
fields: {
href: new TextField({
label: 'https://example.com',
class: 'small',
required: true,
}),
},
callback(attrs) {
toggleMark(markType, attrs)(view.state, view.dispatch);
view.focus();
},
});
return false;
},
});
}
function wrapListItem(nodeType, options) {
return cmdItem(wrapInList(nodeType, options.attrs), options);
}
export function buildMenuItems(schema) {
let r = {};
let type;
if ((type = schema.marks.strong))
r.toggleStrong = markItem(type, {
title: 'Toggle strong style',
icon: icons.strong,
});
if ((type = schema.marks.em))
r.toggleEm = markItem(type, { title: 'Toggle emphasis', icon: icons.em });
if ((type = schema.marks.code))
r.toggleCode = markItem(type, {
title: 'Toggle code font',
icon: icons.code,
});
if ((type = schema.marks.link)) r.toggleLink = linkItem(type);
if ((type = schema.nodes.bullet_list))
r.wrapBulletList = wrapListItem(type, {
title: 'Wrap in bullet list',
icon: icons.bulletList,
});
if ((type = schema.nodes.ordered_list))
r.wrapOrderedList = wrapListItem(type, {
title: 'Wrap in ordered list',
icon: icons.orderedList,
});
let cut = arr => arr.filter(x => x);
r.inlineMenu = [
cut([r.toggleStrong, r.toggleEm, r.toggleCode, r.toggleLink]),
];
r.blockMenu = [cut([r.wrapBulletList, r.wrapOrderedList])];
r.fullMenu = r.inlineMenu.concat([[undoItem, redoItem]], r.blockMenu);
return r;
}

114
src/prompt.js

@ -0,0 +1,114 @@
/* eslint-disable no-plusplus */
const prefix = 'ProseMirror-prompt';
function reportInvalid(dom, message) {
// FIXME this is awful and needs a lot more work
let parent = dom.parentNode;
let msg = parent.appendChild(document.createElement('div'));
msg.style.left = dom.offsetLeft + dom.offsetWidth + 2 + 'px';
msg.style.top = dom.offsetTop - 5 + 'px';
msg.className = 'ProseMirror-invalid';
msg.textContent = message;
setTimeout(() => parent.removeChild(msg), 1500);
}
function getValues(fields, domFields) {
let result = Object.keys(fields)
.filter((name, index) => {
let field = fields[name];
let dom = domFields[index];
let value = field.read(dom);
let bad = field.validate(value);
if (bad) reportInvalid(dom, bad);
return !bad;
})
.reduce((acc, name, index) => {
let field = fields[name];
let dom = domFields[index];
let value = field.read(dom);
acc[name] = field.clean(value);
return acc;
}, {});
return result;
}
export function openPrompt(options) {
let wrapper = document.body.appendChild(document.createElement('div'));
wrapper.className = prefix;
const close = () => {
// eslint-disable-next-line no-use-before-define
window.removeEventListener('mousedown', mouseOutside);
if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper);
};
let mouseOutside = e => {
if (!wrapper.contains(e.target)) close();
};
setTimeout(() => window.addEventListener('mousedown', mouseOutside), 50);
let domFields = [];
Object.values(options.fields).map(field => domFields.push(field.render()));
let submitButton = document.createElement('button');
submitButton.type = 'submit';
submitButton.className =
'button tiny button--save-link ' + prefix + '-submit';
submitButton.textContent = 'Create Link';
let cancelButton = document.createElement('button');
cancelButton.type = 'button';
cancelButton.className = 'button tiny hollow secondary' + prefix + '-cancel';
cancelButton.textContent = 'Cancel';
cancelButton.addEventListener('click', close);
let form = wrapper.appendChild(document.createElement('form'));
if (options.title) {
const titleDom = document.createElement('h5');
titleDom.className = 'sub-block-title';
form.appendChild(titleDom).textContent = options.title;
}
domFields.forEach(field => {
form.appendChild(document.createElement('div')).appendChild(field);
});
let buttons = form.appendChild(document.createElement('div'));
buttons.className = prefix + '-buttons';
buttons.appendChild(submitButton);
buttons.appendChild(document.createTextNode(' '));
buttons.appendChild(cancelButton);
let box = wrapper.getBoundingClientRect();
wrapper.style.top = (window.innerHeight - box.height) / 2 + 'px';
wrapper.style.left = (window.innerWidth - box.width) / 2 + 'px';
let submit = () => {
let params = getValues(options.fields, domFields);
if (params) {
close();
options.callback(params);
}
};
form.addEventListener('submit', e => {
e.preventDefault();
submit();
});
form.addEventListener('keydown', e => {
if (e.key === 'Esc') {
e.preventDefault();
close();
} else if (e.key === 'Enter' && !(e.ctrlKey || e.metaKey || e.shiftKey)) {
e.preventDefault();
submit();
} else if (e.key === 'Tab') {
window.setTimeout(() => {
if (!wrapper.contains(document.activeElement)) close();
}, 500);
}
});
let input = form.elements[0];
if (input) input.focus();
}

251
src/woot-editor.css

@ -0,0 +1,251 @@
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
.ProseMirror-selectednode {
outline: 2px solid var(--w-200);
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: '';
position: absolute;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid var(--w-200);
pointer-events: none;
}
.ProseMirror-textblock-dropdown {
min-width: 3em;
}
.ProseMirror-menu {
margin: 0 -4px;
line-height: 1;
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
margin-right: 3px;
display: inline-block;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-disabled {
opacity: 0.3;
}
.ProseMirror-menubar {
position: relative;
min-height: var(--space-two);
color: var(--color-heading);
padding: var(--space-small) 0;
top: 0;
left: var(--space-minus-small);
right: 0;
background: transparent;
z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
}
.ProseMirror-icon {
display: inline-block;
line-height: 0.8;
vertical-align: -2px;
/* Compensate for padding */
padding: 2px 8px;
cursor: pointer;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
cursor: default;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 2em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: '';
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.ProseMirror ul,
.ProseMirror ol {
padding-left: 30px;
}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
margin-left: 0;
margin-right: 0;
}
.ProseMirror-prompt {
position: fixed;
z-index: 11;
padding: var(--space-normal);
background: white;
}
.ProseMirror-prompt input[type='text'] {
padding: 0 4px;
}
.ProseMirror-prompt-close {
position: absolute;
left: 2px;
top: 1px;
color: #666;
border: none;
background: transparent;
padding: 0;
}
.ProseMirror-prompt-close:after {
content: '✕';
font-size: 12px;
}
.ProseMirror-invalid {
background: #ffc;
border: 1px solid #cc7;
border-radius: 4px;
padding: 5px 10px;
position: absolute;
min-width: 10em;
}
.ProseMirror-prompt-buttons {
display: flex;
flex-direction: row-reverse;
align-items: center;
justify-content: flex-start;
}
.ProseMirror-prompt-buttons .button--save-link {
margin-left: var(--space-smaller);
}
#editor,
.editor {
background: white;
color: black;
background-clip: padding-box;
border-radius: 4px;
border: 2px solid rgba(0, 0, 0, 0.2);
padding: 5px 0;
margin-bottom: 23px;
}
.ProseMirror p:first-child,
.ProseMirror h1:first-child,
.ProseMirror h2:first-child,
.ProseMirror h3:first-child,
.ProseMirror h4:first-child,
.ProseMirror h5:first-child,
.ProseMirror h6:first-child {
margin-top: 10px;
}
.ProseMirror {
padding: 4px 8px 4px 14px;
line-height: 1.2;
outline: none;
}
.ProseMirror p {
margin-bottom: 1em;
}
.ProseMirror .empty-node::before {
position: absolute;
color: #aaa;
cursor: text;
}
.ProseMirror .empty-node:hover::before {
color: #777;
}
.ProseMirror p.empty-node:first-child::before {
content: attr(data-placeholder);
cursor: text;
}
Loading…
Cancel
Save