diff --git a/package-lock.json b/package-lock.json index fdafe80c6..df713a1b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,9 +7,11 @@ "dependencies": { "clipboard": "^2.0.8", "codemirror": "^5.63.3", + "crelt": "^1.0.5", "dropzone": "^5.9.3", "markdown-it": "^12.2.0", "markdown-it-task-lists": "^2.1.1", + "prosemirror-commands": "^1.1.12", "prosemirror-example-setup": "^1.1.2", "prosemirror-markdown": "^1.6.0", "prosemirror-model": "^1.15.0", diff --git a/package.json b/package.json index cb72c2e72..fdf65f63b 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"", "build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify", "build:js:editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main", - "build:js:editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/*.js\" -c \"npm run build:js:editor:dev\"", + "build:js:editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js:editor:dev\"", "build": "npm-run-all --parallel build:*:dev", "production": "npm-run-all --parallel build:*:production", "dev": "npm-run-all --parallel watch livereload", @@ -27,9 +27,11 @@ "dependencies": { "clipboard": "^2.0.8", "codemirror": "^5.63.3", + "crelt": "^1.0.5", "dropzone": "^5.9.3", "markdown-it": "^12.2.0", "markdown-it-task-lists": "^2.1.1", + "prosemirror-commands": "^1.1.12", "prosemirror-example-setup": "^1.1.2", "prosemirror-markdown": "^1.6.0", "prosemirror-model": "^1.15.0", diff --git a/resources/js/editor.js b/resources/js/editor.js index 11e908834..42ec56467 100644 --- a/resources/js/editor.js +++ b/resources/js/editor.js @@ -1,6 +1,7 @@ import MarkdownView from "./editor/MarkdownView"; import ProseMirrorView from "./editor/ProseMirrorView"; +// Next step: https://prosemirror.net/examples/menu/ const place = document.querySelector("#editor"); let view = new ProseMirrorView(place, document.getElementById('content').innerHTML); diff --git a/resources/js/editor/ProseMirrorView.js b/resources/js/editor/ProseMirrorView.js index 1988d6921..69177b63a 100644 --- a/resources/js/editor/ProseMirrorView.js +++ b/resources/js/editor/ProseMirrorView.js @@ -5,6 +5,7 @@ import {exampleSetup} from "prosemirror-example-setup"; import {DOMParser, DOMSerializer} from "prosemirror-model"; import schema from "./schema"; +import menu from "./menu"; class ProseMirrorView { constructor(target, content) { @@ -16,7 +17,10 @@ class ProseMirrorView { this.view = new EditorView(target, { state: EditorState.create({ doc: DOMParser.fromSchema(schema).parse(renderDoc.body), - plugins: exampleSetup({schema}) + plugins: [ + ...exampleSetup({schema, menuBar: false}), + menu, + ] }) }); } diff --git a/resources/js/editor/menu/icons.js b/resources/js/editor/menu/icons.js new file mode 100644 index 000000000..2cc220bdf --- /dev/null +++ b/resources/js/editor/menu/icons.js @@ -0,0 +1,53 @@ +/** + * This file originates from https://github.com/ProseMirror/prosemirror-menu + * and is hence subject to the MIT license found here: + * https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE + * @copyright Marijn Haverbeke and others + */ + +const SVG = "http://www.w3.org/2000/svg" +const XLINK = "http://www.w3.org/1999/xlink" + +const prefix = "ProseMirror-icon" + +function hashPath(path) { + let hash = 0 + for (let i = 0; i < path.length; i++) + hash = (((hash << 5) - hash) + path.charCodeAt(i)) | 0 + return hash +} + +export function getIcon(icon) { + let node = document.createElement("div") + node.className = prefix + if (icon.path) { + let name = "pm-icon-" + hashPath(icon.path).toString(16) + if (!document.getElementById(name)) buildSVG(name, icon) + let svg = node.appendChild(document.createElementNS(SVG, "svg")) + svg.style.width = (icon.width / icon.height) + "em" + let use = svg.appendChild(document.createElementNS(SVG, "use")) + use.setAttributeNS(XLINK, "href", /([^#]*)/.exec(document.location)[1] + "#" + name) + } else if (icon.dom) { + node.appendChild(icon.dom.cloneNode(true)) + } else { + node.appendChild(document.createElement("span")).textContent = icon.text || '' + if (icon.css) node.firstChild.style.cssText = icon.css + } + return node +} + +function buildSVG(name, data) { + let collection = document.getElementById(prefix + "-collection") + if (!collection) { + collection = document.createElementNS(SVG, "svg") + collection.id = prefix + "-collection" + collection.style.display = "none" + document.body.insertBefore(collection, document.body.firstChild) + } + let sym = document.createElementNS(SVG, "symbol") + sym.id = name + sym.setAttribute("viewBox", "0 0 " + data.width + " " + data.height) + let path = sym.appendChild(document.createElementNS(SVG, "path")) + path.setAttribute("d", data.path) + collection.appendChild(sym) +} diff --git a/resources/js/editor/menu/index.js b/resources/js/editor/menu/index.js new file mode 100644 index 000000000..1bdc718dc --- /dev/null +++ b/resources/js/editor/menu/index.js @@ -0,0 +1,132 @@ +/** + * Much of this code originates from https://github.com/ProseMirror/prosemirror-menu + * and is hence subject to the MIT license found here: + * https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE + * @copyright Marijn Haverbeke and others + */ + +import { + MenuItem, Dropdown, DropdownSubmenu, renderGrouped, icons, joinUpItem, liftItem, selectParentNodeItem, + undoItem, redoItem, wrapItem, blockTypeItem +} from "./menu" + +import {toggleMark} from "prosemirror-commands"; +import {menuBar} from "./menubar" +import schema from "../schema"; + + +function cmdItem(cmd, options) { + const passedOptions = { + label: options.title, + run: cmd + }; + for (const prop in options) { + passedOptions[prop] = options[prop]; + } + if ((!options.enable || options.enable === true) && !options.select) { + passedOptions[options.enable ? "enable" : "select"] = function (state) { + return cmd(state); + }; + } + + return new MenuItem(passedOptions) +} + +function markActive(state, type) { + const ref = state.selection; + const from = ref.from; + const $from = ref.$from; + const to = ref.to; + const empty = ref.empty; + if (empty) { + return type.isInSet(state.storedMarks || $from.marks()) + } else { + return state.doc.rangeHasMark(from, to, type) + } +} + +function markItem(markType, options) { + const passedOptions = { + active: function active(state) { + return markActive(state, markType) + }, + enable: true + }; + for (const prop in options) { + passedOptions[prop] = options[prop]; + } + + return cmdItem(toggleMark(markType), passedOptions) +} + +const inlineStyles = [ + markItem(schema.marks.strong, {title: "Bold", icon: icons.strong}), + markItem(schema.marks.em, {title: "Italic", icon: icons.em}), +]; + +const formats = [ + blockTypeItem(schema.nodes.heading, { + label: "Header Large", + attrs: {level: 2} + }), + blockTypeItem(schema.nodes.heading, { + label: "Header Medium", + attrs: {level: 3} + }), + blockTypeItem(schema.nodes.heading, { + label: "Header Small", + attrs: {level: 4} + }), + blockTypeItem(schema.nodes.heading, { + label: "Header Tiny", + attrs: {level: 5} + }), + blockTypeItem(schema.nodes.paragraph, { + label: "Paragraph", + attrs: {} + }), + new DropdownSubmenu([ + blockTypeItem(schema.nodes.callout, { + label: "Info Callout", + attrs: {type: 'info'} + }), + blockTypeItem(schema.nodes.callout, { + label: "Danger Callout", + attrs: {type: 'danger'} + }), + blockTypeItem(schema.nodes.callout, { + label: "Success Callout", + attrs: {type: 'success'} + }), + blockTypeItem(schema.nodes.callout, { + label: "Warning Callout", + attrs: {type: 'warning'} + }) + ], { label: 'Callouts' }), +]; + +const menu = menuBar({ + floating: false, + content: [ + [undoItem, redoItem], + inlineStyles, + [new DropdownSubmenu(formats, { label: 'Formats' })], + + ], +}); + +export default menu; + +// !! This module defines a number of building blocks for ProseMirror +// menus, along with a [menu bar](#menu.menuBar) implementation. + +// MenuElement:: interface +// The types defined in this module aren't the only thing you can +// display in your menu. Anything that conforms to this interface can +// be put into a menu structure. +// +// render:: (pm: EditorView) → {dom: dom.Node, update: (EditorState) → bool} +// Render the element for display in the menu. Must return a DOM +// element and a function that can be used to update the element to +// a new state. The `update` function will return false if the +// update hid the entire element. diff --git a/resources/js/editor/menu/menu.js b/resources/js/editor/menu/menu.js new file mode 100644 index 000000000..ceba90fa9 --- /dev/null +++ b/resources/js/editor/menu/menu.js @@ -0,0 +1,464 @@ +/** + * This file originates from https://github.com/ProseMirror/prosemirror-menu + * and is hence subject to the MIT license found here: + * https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE + * @copyright Marijn Haverbeke and others + */ + +import crel from "crelt" +import {lift, joinUp, selectParentNode, wrapIn, setBlockType} from "prosemirror-commands" +import {undo, redo} from "prosemirror-history" + +import {getIcon} from "./icons" + +const prefix = "ProseMirror-menu" + +// ::- An icon or label that, when clicked, executes a command. +export class MenuItem { + // :: (MenuItemSpec) + constructor(spec) { + // :: MenuItemSpec + // The spec used to create the menu item. + this.spec = spec + } + + // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool} + // Renders the icon according to its [display + // spec](#menu.MenuItemSpec.display), and adds an event handler which + // executes the command when the representation is clicked. + render(view) { + let spec = this.spec + let dom = spec.render ? spec.render(view) + : spec.icon ? getIcon(spec.icon) + : spec.label ? crel("div", null, translate(view, spec.label)) + : null + if (!dom) throw new RangeError("MenuItem without icon or label property") + if (spec.title) { + const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title) + dom.setAttribute("title", translate(view, title)) + } + if (spec.class) dom.classList.add(spec.class) + if (spec.css) dom.style.cssText += spec.css + + dom.addEventListener("mousedown", e => { + e.preventDefault() + if (!dom.classList.contains(prefix + "-disabled")) + spec.run(view.state, view.dispatch, view, e) + }) + + function update(state) { + if (spec.select) { + let selected = spec.select(state) + dom.style.display = selected ? "" : "none" + if (!selected) return false + } + let enabled = true + if (spec.enable) { + enabled = spec.enable(state) || false + setClass(dom, prefix + "-disabled", !enabled) + } + if (spec.active) { + let active = enabled && spec.active(state) || false + setClass(dom, prefix + "-active", active) + } + return true + } + + return {dom, update} + } +} + +function translate(view, text) { + return view._props.translate ? view._props.translate(text) : text +} + +// MenuItemSpec:: interface +// The configuration object passed to the `MenuItem` constructor. +// +// run:: (EditorState, (Transaction), EditorView, dom.Event) +// The function to execute when the menu item is activated. +// +// select:: ?(EditorState) → bool +// Optional function that is used to determine whether the item is +// appropriate at the moment. Deselected items will be hidden. +// +// enable:: ?(EditorState) → bool +// Function that is used to determine if the item is enabled. If +// given and returning false, the item will be given a disabled +// styling. +// +// active:: ?(EditorState) → bool +// A predicate function to determine whether the item is 'active' (for +// example, the item for toggling the strong mark might be active then +// the cursor is in strong text). +// +// render:: ?(EditorView) → dom.Node +// A function that renders the item. You must provide either this, +// [`icon`](#menu.MenuItemSpec.icon), or [`label`](#MenuItemSpec.label). +// +// icon:: ?Object +// Describes an icon to show for this item. The object may specify +// an SVG icon, in which case its `path` property should be an [SVG +// path +// spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d), +// and `width` and `height` should provide the viewbox in which that +// path exists. Alternatively, it may have a `text` property +// specifying a string of text that makes up the icon, with an +// optional `css` property giving additional CSS styling for the +// text. _Or_ it may contain `dom` property containing a DOM node. +// +// label:: ?string +// Makes the item show up as a text label. Mostly useful for items +// wrapped in a [drop-down](#menu.Dropdown) or similar menu. The object +// should have a `label` property providing the text to display. +// +// title:: ?union +// Defines DOM title (mouseover) text for the item. +// +// class:: ?string +// Optionally adds a CSS class to the item's DOM representation. +// +// css:: ?string +// Optionally adds a string of inline CSS to the item's DOM +// representation. + +let lastMenuEvent = {time: 0, node: null} +function markMenuEvent(e) { + lastMenuEvent.time = Date.now() + lastMenuEvent.node = e.target +} +function isMenuEvent(wrapper) { + return Date.now() - 100 < lastMenuEvent.time && + lastMenuEvent.node && wrapper.contains(lastMenuEvent.node) +} + +// ::- A drop-down menu, displayed as a label with a downwards-pointing +// triangle to the right of it. +export class Dropdown { + // :: ([MenuElement], ?Object) + // Create a dropdown wrapping the elements. Options may include + // the following properties: + // + // **`label`**`: string` + // : The label to show on the drop-down control. + // + // **`title`**`: string` + // : Sets the + // [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title) + // attribute given to the menu control. + // + // **`class`**`: string` + // : When given, adds an extra CSS class to the menu control. + // + // **`css`**`: string` + // : When given, adds an extra set of CSS styles to the menu control. + constructor(content, options) { + this.options = options || {} + this.content = Array.isArray(content) ? content : [content] + } + + // :: (EditorView) → {dom: dom.Node, update: (EditorState)} + // Render the dropdown menu and sub-items. + render(view) { + let content = renderDropdownItems(this.content, view) + + let label = crel("div", {class: prefix + "-dropdown " + (this.options.class || ""), + style: this.options.css}, + translate(view, this.options.label)) + if (this.options.title) label.setAttribute("title", translate(view, this.options.title)) + let wrap = crel("div", {class: prefix + "-dropdown-wrap"}, label) + let open = null, listeningOnClose = null + let close = () => { + if (open && open.close()) { + open = null + window.removeEventListener("mousedown", listeningOnClose) + } + } + label.addEventListener("mousedown", e => { + e.preventDefault() + markMenuEvent(e) + if (open) { + close() + } else { + open = this.expand(wrap, content.dom) + window.addEventListener("mousedown", listeningOnClose = () => { + if (!isMenuEvent(wrap)) close() + }) + } + }) + + function update(state) { + let inner = content.update(state) + wrap.style.display = inner ? "" : "none" + return inner + } + + return {dom: wrap, update} + } + + expand(dom, items) { + let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items) + + let done = false + function close() { + if (done) return + done = true + dom.removeChild(menuDOM) + return true + } + dom.appendChild(menuDOM) + return {close, node: menuDOM} + } +} + +function renderDropdownItems(items, view) { + let rendered = [], updates = [] + for (let i = 0; i < items.length; i++) { + let {dom, update} = items[i].render(view) + rendered.push(crel("div", {class: prefix + "-dropdown-item"}, dom)) + updates.push(update) + } + return {dom: rendered, update: combineUpdates(updates, rendered)} +} + +function combineUpdates(updates, nodes) { + return state => { + let something = false + for (let i = 0; i < updates.length; i++) { + let up = updates[i](state) + nodes[i].style.display = up ? "" : "none" + if (up) something = true + } + return something + } +} + +// ::- Represents a submenu wrapping a group of elements that start +// hidden and expand to the right when hovered over or tapped. +export class DropdownSubmenu { + // :: ([MenuElement], ?Object) + // Creates a submenu for the given group of menu elements. The + // following options are recognized: + // + // **`label`**`: string` + // : The label to show on the submenu. + constructor(content, options) { + this.options = options || {} + this.content = Array.isArray(content) ? content : [content] + } + + // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool} + // Renders the submenu. + render(view) { + let items = renderDropdownItems(this.content, view) + + let label = crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label)) + let wrap = crel("div", {class: prefix + "-submenu-wrap"}, label, + crel("div", {class: prefix + "-submenu"}, items.dom)) + let listeningOnClose = null + label.addEventListener("mousedown", e => { + e.preventDefault() + markMenuEvent(e) + setClass(wrap, prefix + "-submenu-wrap-active") + if (!listeningOnClose) + window.addEventListener("mousedown", listeningOnClose = () => { + if (!isMenuEvent(wrap)) { + wrap.classList.remove(prefix + "-submenu-wrap-active") + window.removeEventListener("mousedown", listeningOnClose) + listeningOnClose = null + } + }) + }) + + function update(state) { + let inner = items.update(state) + wrap.style.display = inner ? "" : "none" + return inner + } + return {dom: wrap, update} + } +} + +// :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool} +// Render the given, possibly nested, array of menu elements into a +// document fragment, placing separators between them (and ensuring no +// superfluous separators appear when some of the groups turn out to +// be empty). +export function renderGrouped(view, content) { + let result = document.createDocumentFragment() + let updates = [], separators = [] + for (let i = 0; i < content.length; i++) { + let items = content[i], localUpdates = [], localNodes = [] + for (let j = 0; j < items.length; j++) { + let {dom, update} = items[j].render(view) + let span = crel("span", {class: prefix + "item"}, dom) + result.appendChild(span) + localNodes.push(span) + localUpdates.push(update) + } + if (localUpdates.length) { + updates.push(combineUpdates(localUpdates, localNodes)) + if (i < content.length - 1) + separators.push(result.appendChild(separator())) + } + } + + function update(state) { + let something = false, needSep = false + for (let i = 0; i < updates.length; i++) { + let hasContent = updates[i](state) + if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none" + needSep = hasContent + if (hasContent) something = true + } + return something + } + return {dom: result, update} +} + +function separator() { + return crel("span", {class: prefix + "separator"}) +} + +// :: Object +// A set of basic editor-related icons. Contains the properties +// `join`, `lift`, `selectParentNode`, `undo`, `redo`, `strong`, `em`, +// `code`, `link`, `bulletList`, `orderedList`, and `blockquote`, each +// holding an object that can be used as the `icon` option to +// `MenuItem`. +export const icons = { + join: { + width: 800, height: 900, + path: "M0 75h800v125h-800z M0 825h800v-125h-800z M250 400h100v-100h100v100h100v100h-100v100h-100v-100h-100z" + }, + lift: { + width: 1024, height: 1024, + path: "M219 310v329q0 7-5 12t-12 5q-8 0-13-5l-164-164q-5-5-5-13t5-13l164-164q5-5 13-5 7 0 12 5t5 12zM1024 749v109q0 7-5 12t-12 5h-987q-7 0-12-5t-5-12v-109q0-7 5-12t12-5h987q7 0 12 5t5 12zM1024 530v109q0 7-5 12t-12 5h-621q-7 0-12-5t-5-12v-109q0-7 5-12t12-5h621q7 0 12 5t5 12zM1024 310v109q0 7-5 12t-12 5h-621q-7 0-12-5t-5-12v-109q0-7 5-12t12-5h621q7 0 12 5t5 12zM1024 91v109q0 7-5 12t-12 5h-987q-7 0-12-5t-5-12v-109q0-7 5-12t12-5h987q7 0 12 5t5 12z" + }, + selectParentNode: {text: "\u2b1a", css: "font-weight: bold"}, + undo: { + width: 1024, height: 1024, + path: "M761 1024c113-206 132-520-313-509v253l-384-384 384-384v248c534-13 594 472 313 775z" + }, + redo: { + width: 1024, height: 1024, + path: "M576 248v-248l384 384-384 384v-253c-446-10-427 303-313 509-280-303-221-789 313-775z" + }, + strong: { + width: 805, height: 1024, + path: "M317 869q42 18 80 18 214 0 214-191 0-65-23-102-15-25-35-42t-38-26-46-14-48-6-54-1q-41 0-57 5 0 30-0 90t-0 90q0 4-0 38t-0 55 2 47 6 38zM309 442q24 4 62 4 46 0 81-7t62-25 42-51 14-81q0-40-16-70t-45-46-61-24-70-8q-28 0-74 7 0 28 2 86t2 86q0 15-0 45t-0 45q0 26 0 39zM0 950l1-53q8-2 48-9t60-15q4-6 7-15t4-19 3-18 1-21 0-19v-37q0-561-12-585-2-4-12-8t-25-6-28-4-27-2-17-1l-2-47q56-1 194-6t213-5q13 0 39 0t38 0q40 0 78 7t73 24 61 40 42 59 16 78q0 29-9 54t-22 41-36 32-41 25-48 22q88 20 146 76t58 141q0 57-20 102t-53 74-78 48-93 27-100 8q-25 0-75-1t-75-1q-60 0-175 6t-132 6z" + }, + em: { + width: 585, height: 1024, + path: "M0 949l9-48q3-1 46-12t63-21q16-20 23-57 0-4 35-165t65-310 29-169v-14q-13-7-31-10t-39-4-33-3l10-58q18 1 68 3t85 4 68 1q27 0 56-1t69-4 56-3q-2 22-10 50-17 5-58 16t-62 19q-4 10-8 24t-5 22-4 26-3 24q-15 84-50 239t-44 203q-1 5-7 33t-11 51-9 47-3 32l0 10q9 2 105 17-1 25-9 56-6 0-18 0t-18 0q-16 0-49-5t-49-5q-78-1-117-1-29 0-81 5t-69 6z" + }, + code: { + width: 896, height: 1024, + path: "M608 192l-96 96 224 224-224 224 96 96 288-320-288-320zM288 192l-288 320 288 320 96-96-224-224 224-224-96-96z" + }, + link: { + width: 951, height: 1024, + path: "M832 694q0-22-16-38l-118-118q-16-16-38-16-24 0-41 18 1 1 10 10t12 12 8 10 7 14 2 15q0 22-16 38t-38 16q-8 0-15-2t-14-7-10-8-12-12-10-10q-18 17-18 41 0 22 16 38l117 118q15 15 38 15 22 0 38-14l84-83q16-16 16-38zM430 292q0-22-16-38l-117-118q-16-16-38-16-22 0-38 15l-84 83q-16 16-16 38 0 22 16 38l118 118q15 15 38 15 24 0 41-17-1-1-10-10t-12-12-8-10-7-14-2-15q0-22 16-38t38-16q8 0 15 2t14 7 10 8 12 12 10 10q18-17 18-41zM941 694q0 68-48 116l-84 83q-47 47-116 47-69 0-116-48l-117-118q-47-47-47-116 0-70 50-119l-50-50q-49 50-118 50-68 0-116-48l-118-118q-48-48-48-116t48-116l84-83q47-47 116-47 69 0 116 48l117 118q47 47 47 116 0 70-50 119l50 50q49-50 118-50 68 0 116 48l118 118q48 48 48 116z" + }, + bulletList: { + width: 768, height: 896, + path: "M0 512h128v-128h-128v128zM0 256h128v-128h-128v128zM0 768h128v-128h-128v128zM256 512h512v-128h-512v128zM256 256h512v-128h-512v128zM256 768h512v-128h-512v128z" + }, + orderedList: { + width: 768, height: 896, + path: "M320 512h448v-128h-448v128zM320 768h448v-128h-448v128zM320 128v128h448v-128h-448zM79 384h78v-256h-36l-85 23v50l43-2v185zM189 590c0-36-12-78-96-78-33 0-64 6-83 16l1 66c21-10 42-15 67-15s32 11 32 28c0 26-30 58-110 112v50h192v-67l-91 2c49-30 87-66 87-113l1-1z" + }, + blockquote: { + width: 640, height: 896, + path: "M0 448v256h256v-256h-128c0 0 0-128 128-128v-128c0 0-256 0-256 256zM640 320v-128c0 0-256 0-256 256v256h256v-256h-128c0 0 0-128 128-128z" + } +} + +// :: MenuItem +// Menu item for the `joinUp` command. +export const joinUpItem = new MenuItem({ + title: "Join with above block", + run: joinUp, + select: state => joinUp(state), + icon: icons.join +}) + +// :: MenuItem +// Menu item for the `lift` command. +export const liftItem = new MenuItem({ + title: "Lift out of enclosing block", + run: lift, + select: state => lift(state), + icon: icons.lift +}) + +// :: MenuItem +// Menu item for the `selectParentNode` command. +export const selectParentNodeItem = new MenuItem({ + title: "Select parent node", + run: selectParentNode, + select: state => selectParentNode(state), + icon: icons.selectParentNode +}) + +// :: MenuItem +// Menu item for the `undo` command. +export let undoItem = new MenuItem({ + title: "Undo last change", + run: undo, + enable: state => undo(state), + icon: icons.undo +}) + +// :: MenuItem +// Menu item for the `redo` command. +export let redoItem = new MenuItem({ + title: "Redo last undone change", + run: redo, + enable: state => redo(state), + icon: icons.redo +}) + +// :: (NodeType, Object) → MenuItem +// Build a menu item for wrapping the selection in a given node type. +// Adds `run` and `select` properties to the ones present in +// `options`. `options.attrs` may be an object or a function. +export function wrapItem(nodeType, options) { + let passedOptions = { + run(state, dispatch) { + // FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state)) + return wrapIn(nodeType, options.attrs)(state, dispatch) + }, + select(state) { + return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state) + } + } + for (let prop in options) passedOptions[prop] = options[prop] + return new MenuItem(passedOptions) +} + +// :: (NodeType, Object) → MenuItem +// Build a menu item for changing the type of the textblock around the +// selection to the given type. Provides `run`, `active`, and `select` +// properties. Others must be given in `options`. `options.attrs` may +// be an object to provide the attributes for the textblock node. +export function blockTypeItem(nodeType, options) { + let command = setBlockType(nodeType, options.attrs) + let passedOptions = { + run: command, + enable(state) { return command(state) }, + active(state) { + let {$from, to, node} = state.selection + if (node) return node.hasMarkup(nodeType, options.attrs) + return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs) + } + } + for (let prop in options) passedOptions[prop] = options[prop] + return new MenuItem(passedOptions) +} + +// Work around classList.toggle being broken in IE11 +function setClass(dom, cls, on) { + if (on) dom.classList.add(cls) + else dom.classList.remove(cls) +} diff --git a/resources/js/editor/menu/menubar.js b/resources/js/editor/menu/menubar.js new file mode 100644 index 000000000..c6461bfab --- /dev/null +++ b/resources/js/editor/menu/menubar.js @@ -0,0 +1,163 @@ +/** + * This file originates from https://github.com/ProseMirror/prosemirror-menu + * and is hence subject to the MIT license found here: + * https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE + * @copyright Marijn Haverbeke and others + */ + +import crel from "crelt" +import {Plugin} from "prosemirror-state" + +import {renderGrouped} from "./menu" + +const prefix = "ProseMirror-menubar" + +function isIOS() { + if (typeof navigator == "undefined") return false + let agent = navigator.userAgent + return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent) +} + +// :: (Object) → Plugin +// A plugin that will place a menu bar above the editor. Note that +// this involves wrapping the editor in an additional `
`. +// +// options::- +// Supports the following options: +// +// content:: [[MenuElement]] +// Provides the content of the menu, as a nested array to be +// passed to `renderGrouped`. +// +// floating:: ?bool +// Determines whether the menu floats, i.e. whether it sticks to +// the top of the viewport when the editor is partially scrolled +// out of view. +export function menuBar(options) { + return new Plugin({ + view(editorView) { return new MenuBarView(editorView, options) } + }) +} + +class MenuBarView { + constructor(editorView, options) { + this.editorView = editorView + this.options = options + + this.wrapper = crel("div", {class: prefix + "-wrapper"}) + this.menu = this.wrapper.appendChild(crel("div", {class: prefix})) + this.menu.className = prefix + this.spacer = null + + if (editorView.dom.parentNode) + editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom) + this.wrapper.appendChild(editorView.dom) + + this.maxHeight = 0 + this.widthForMaxHeight = 0 + this.floating = false + + let {dom, update} = renderGrouped(this.editorView, this.options.content) + this.contentUpdate = update + this.menu.appendChild(dom) + this.update() + + if (options.floating && !isIOS()) { + this.updateFloat() + let potentialScrollers = getAllWrapping(this.wrapper) + this.scrollFunc = (e) => { + let root = this.editorView.root + if (!(root.body || root).contains(this.wrapper)) { + potentialScrollers.forEach(el => el.removeEventListener("scroll", this.scrollFunc)) + } else { + this.updateFloat(e.target.getBoundingClientRect && e.target) + } + } + potentialScrollers.forEach(el => el.addEventListener('scroll', this.scrollFunc)) + } + } + + update() { + this.contentUpdate(this.editorView.state) + + if (this.floating) { + this.updateScrollCursor() + } else { + if (this.menu.offsetWidth != this.widthForMaxHeight) { + this.widthForMaxHeight = this.menu.offsetWidth + this.maxHeight = 0 + } + if (this.menu.offsetHeight > this.maxHeight) { + this.maxHeight = this.menu.offsetHeight + this.menu.style.minHeight = this.maxHeight + "px" + } + } + } + + updateScrollCursor() { + let selection = this.editorView.root.getSelection() + if (!selection.focusNode) return + let rects = selection.getRangeAt(0).getClientRects() + let selRect = rects[selectionIsInverted(selection) ? 0 : rects.length - 1] + if (!selRect) return + let menuRect = this.menu.getBoundingClientRect() + if (selRect.top < menuRect.bottom && selRect.bottom > menuRect.top) { + let scrollable = findWrappingScrollable(this.wrapper) + if (scrollable) scrollable.scrollTop -= (menuRect.bottom - selRect.top) + } + } + + updateFloat(scrollAncestor) { + let parent = this.wrapper, editorRect = parent.getBoundingClientRect(), + top = scrollAncestor ? Math.max(0, scrollAncestor.getBoundingClientRect().top) : 0 + + if (this.floating) { + if (editorRect.top >= top || editorRect.bottom < this.menu.offsetHeight + 10) { + this.floating = false + this.menu.style.position = this.menu.style.left = this.menu.style.top = this.menu.style.width = "" + this.menu.style.display = "" + this.spacer.parentNode.removeChild(this.spacer) + this.spacer = null + } else { + let border = (parent.offsetWidth - parent.clientWidth) / 2 + this.menu.style.left = (editorRect.left + border) + "px" + this.menu.style.display = (editorRect.top > window.innerHeight ? "none" : "") + if (scrollAncestor) this.menu.style.top = top + "px" + } + } else { + if (editorRect.top < top && editorRect.bottom >= this.menu.offsetHeight + 10) { + this.floating = true + let menuRect = this.menu.getBoundingClientRect() + this.menu.style.left = menuRect.left + "px" + this.menu.style.width = menuRect.width + "px" + if (scrollAncestor) this.menu.style.top = top + "px" + this.menu.style.position = "fixed" + this.spacer = crel("div", {class: prefix + "-spacer", style: `height: ${menuRect.height}px`}) + parent.insertBefore(this.spacer, this.menu) + } + } + } + + destroy() { + if (this.wrapper.parentNode) + this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper) + } +} + +// Not precise, but close enough +function selectionIsInverted(selection) { + if (selection.anchorNode == selection.focusNode) return selection.anchorOffset > selection.focusOffset + return selection.anchorNode.compareDocumentPosition(selection.focusNode) == Node.DOCUMENT_POSITION_FOLLOWING +} + +function findWrappingScrollable(node) { + for (let cur = node.parentNode; cur; cur = cur.parentNode) + if (cur.scrollHeight > cur.clientHeight) return cur +} + +function getAllWrapping(node) { + let res = [window] + for (let cur = node.parentNode; cur; cur = cur.parentNode) + res.push(cur) + return res +} diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss new file mode 100644 index 000000000..d24dfaec5 --- /dev/null +++ b/resources/sass/_editor.scss @@ -0,0 +1,368 @@ + + +#editor.bs-editor { + padding-top: 0; +} + +//.bs-editor .menubar { +// border-bottom: 1px solid #DDD; +// padding: 2px; +//} +// +//.bs-editor .menuicon { +// cursor: pointer; +// padding: 4px; +// min-width: 2rem; +// border-radius: 3px; +// border: 1px solid transparent; +// &:hover { +// background-color: #EEE; +// border: 1px solid #DDD; +// } +//} + +// The below originated from https://github.com/ProseMirror/prosemirror-menu +// and is therefore subject to the MIT license found here: +// https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE + +.ProseMirror { + position: relative; +} + +.ProseMirror { + word-wrap: break-word; + white-space: pre-wrap; + white-space: break-spaces; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; + font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */ +} + +.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 #8cf; +} + +/* 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 #8cf; + pointer-events: none; +} + +/* Protect against generic img rules */ + +img.ProseMirror-separator { + display: inline !important; + border: none !important; + margin: 0 !important; +} + +.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-menuseparator { + border-right: 1px solid #ddd; + margin-right: 3px; +} + +.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { + font-size: 90%; + white-space: nowrap; +} + +.ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding-right: 15px; +} + +.ProseMirror-menu-dropdown-wrap { + padding: 1px 0 1px 4px; + display: inline-block; + position: relative; +} + +.ProseMirror-menu-dropdown:after { + content: ""; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 2px); +} + +.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { + position: absolute; + background: white; + color: #666; + border: 1px solid #aaa; + padding: 2px; +} + +.ProseMirror-menu-dropdown-menu { + z-index: 15; + min-width: 6em; +} + +.ProseMirror-menu-dropdown-item { + cursor: pointer; + padding: 2px 8px 2px 4px; +} + +.ProseMirror-menu-dropdown-item:hover { + background: #f2f2f2; +} + +.ProseMirror-menu-submenu-wrap { + position: relative; + margin-right: 4px; +} + +.ProseMirror-menu-submenu-label { + padding-inline-end: 12px; + padding-inline-start: 4px; +} + +.ProseMirror-menu-submenu-label:after { + content: ""; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 4px); +} + +.ProseMirror-menu-submenu { + display: none; + min-width: 10em; + left: 100%; + top: -3px; +} + +.ProseMirror-menu-active { + background: #eee; + border-radius: 4px; +} + +.ProseMirror-menu-disabled { + opacity: .3; +} + +.ProseMirror-menu-submenu-wrap:hover > .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active > .ProseMirror-menu-submenu { + display: block; +} + +.ProseMirror-menubar { + border-top-left-radius: inherit; + border-top-right-radius: inherit; + position: relative; + min-height: 1em; + color: #666; + padding: 1px 6px; + top: 0; left: 0; right: 0; + border-bottom: 1px solid silver; + background: white; + z-index: 10; + -moz-box-sizing: border-box; + box-sizing: border-box; + overflow: visible; +} + +.ProseMirror-icon { + display: inline-block; + line-height: .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: 1em; +} + +.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; +} +/* Add space around the hr to make clicking it easier */ + +.ProseMirror-example-setup-style hr { + padding: 2px 10px; + border: none; + margin: 1em 0; +} + +.ProseMirror-example-setup-style hr:after { + content: ""; + display: block; + height: 1px; + background-color: silver; + line-height: 2px; +} + +.ProseMirror ul, .ProseMirror ol { + padding-left: 30px; +} + +.ProseMirror blockquote { + padding-left: 1em; + border-left: 3px solid #eee; + margin-left: 0; margin-right: 0; +} + +.ProseMirror-example-setup-style img { + cursor: default; +} + +.ProseMirror-prompt { + background: white; + padding: 5px 10px 5px 15px; + border: 1px solid silver; + position: fixed; + border-radius: 3px; + z-index: 11; + box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); +} + +.ProseMirror-prompt h5 { + margin: 0; + font-weight: normal; + font-size: 100%; + color: #444; +} + +.ProseMirror-prompt input[type="text"], +.ProseMirror-prompt textarea { + background: #eee; + border: none; + outline: none; +} + +.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 { + margin-top: 5px; + display: none; +} +#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 } \ No newline at end of file diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 582bf7c75..9d8ed8465 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -20,6 +20,7 @@ @import "footer"; @import "lists"; @import "pages"; +@import "editor"; // Jquery Sortable Styles .dragged { diff --git a/resources/views/editor-test.blade.php b/resources/views/editor-test.blade.php index ee60a2dd3..bba8c3eca 100644 --- a/resources/views/editor-test.blade.php +++ b/resources/views/editor-test.blade.php @@ -1,9 +1,5 @@ @extends('layouts.simple') -@section('head') - -@endsection - @section('body')
@@ -11,7 +7,7 @@
-
+