diff --git a/TODO b/TODO index f93f5c1f1..fbba22f50 100644 --- a/TODO +++ b/TODO @@ -1,4 +1,10 @@ -- Render color picker view menu item. +### In-Progress + +- Modal Dialogs for details such as links + - Got dialog + form + input ready + - Next stage is creating a button (eg, anchor insert) which toggles/shows dialog box. + - Dialog box should attach at bottom of dom (Prevent z-index issues). + - At some level a layer is needed to wire up the existing components. ### Features diff --git a/resources/js/editor/menu/ColorPickerGrid.js b/resources/js/editor/menu/ColorPickerGrid.js index 91ea73317..c5eacc335 100644 --- a/resources/js/editor/menu/ColorPickerGrid.js +++ b/resources/js/editor/menu/ColorPickerGrid.js @@ -1,5 +1,5 @@ import crel from "crelt" -const prefix = "ProseMirror-menu" +import {prefix} from "./menu-utils"; import {toggleMark} from "prosemirror-commands"; class ColorPickerGrid { diff --git a/resources/js/editor/menu/DialogBox.js b/resources/js/editor/menu/DialogBox.js new file mode 100644 index 000000000..a9aa443da --- /dev/null +++ b/resources/js/editor/menu/DialogBox.js @@ -0,0 +1,63 @@ +// ::- Represents a submenu wrapping a group of elements that start +// hidden and expand to the right when hovered over or tapped. +import {prefix, renderItems} from "./menu-utils"; +import crel from "crelt"; +import {getIcon, icons} from "./icons"; + +class DialogBox { + // :: ([MenuElement], ?Object) + // The following options are recognized: + // + // **`label`**`: string` + // : The label to show on the dialog. + // **`closer`**`: function` + // : The function to run when the dialog should close. + constructor(content, options) { + this.options = options || {}; + this.content = Array.isArray(content) ? content : [content]; + + this.closeMouseDownListener = null; + this.wrap = null; + } + + // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool} + // Renders the submenu. + render(view) { + const items = renderItems(this.content, view) + + const titleText = crel("div", {class: prefix + "-dialog-title-text"}, this.options.label); + const titleClose = crel("button", {class: prefix + "-dialog-title-close primary-background", type: "button"}, getIcon(icons.close)); + const titleContent = crel("div", {class: prefix + "-dialog-title"}, titleText, titleClose); + const dialog = crel("div", {class: prefix + "-dialog"}, titleContent, + crel("div", {class: prefix + "-dialog-content"}, items.dom)); + const wrap = crel("div", {class: prefix + "-dialog-wrap"}, dialog); + this.wrap = wrap; + + this.closeMouseDownListener = (event) => { + if (!dialog.contains(event.target) || titleClose.contains(event.target)) { + this.close(); + } + } + + wrap.addEventListener("click", this.closeMouseDownListener); + + function update(state) { + let inner = items.update(state) + wrap.style.display = inner ? "" : "none" + return inner; + } + return {dom: wrap, update} + } + + close() { + if (this.options.closer) { + this.options.closer(); + } + + if (this.closeMouseDownListener) { + this.wrap.removeEventListener("click", this.closeMouseDownListener); + } + } +} + +export default DialogBox; \ No newline at end of file diff --git a/resources/js/editor/menu/DialogForm.js b/resources/js/editor/menu/DialogForm.js new file mode 100644 index 000000000..3827f1f74 --- /dev/null +++ b/resources/js/editor/menu/DialogForm.js @@ -0,0 +1,51 @@ +// ::- Represents a submenu wrapping a group of elements that start +// hidden and expand to the right when hovered over or tapped. +import {prefix, renderItems} from "./menu-utils"; +import crel from "crelt"; + +class DialogForm { + // :: ([MenuElement], ?Object) + // The following options are recognized: + // + // **`action`**`: function(FormData)` + // : The submission action to run when the form is submitted. + // **`canceler`**`: function` + // : The cancel action to run when the form is cancelled. + 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) { + const items = renderItems(this.content, view) + + const formButtonCancel = crel("button", {class: prefix + "-dialog-button", type: "button"}, "Cancel"); + const formButtonSave = crel("button", {class: prefix + "-dialog-button", type: "submit"}, "Save"); + const footer = crel("div", {class: prefix + "-dialog-footer"}, formButtonCancel, formButtonSave); + const form = crel("form", {class: prefix + "-dialog-form", action: '#'}, items.dom, footer); + + form.addEventListener('submit', event => { + event.preventDefault(); + if (this.options.action) { + this.options.action(new FormData(form)); + } + }); + + formButtonCancel.addEventListener('click', event => { + if (this.options.canceler) { + this.options.canceler(); + } + }); + + function update(state) { + return items.update(state); + } + + return {dom: form, update} + } + +} + +export default DialogForm; \ No newline at end of file diff --git a/resources/js/editor/menu/DialogInput.js b/resources/js/editor/menu/DialogInput.js new file mode 100644 index 000000000..dabbb2326 --- /dev/null +++ b/resources/js/editor/menu/DialogInput.js @@ -0,0 +1,42 @@ +// ::- Represents a submenu wrapping a group of elements that start +// hidden and expand to the right when hovered over or tapped. +import {prefix, randHtmlId} from "./menu-utils"; +import crel from "crelt"; + +class DialogInput { + // :: (?Object) + // The following options are recognized: + // + // **`label`**`: string` + // : The label to show for the input. + // **`id`**`: string` + // : The id to use for this input + // **`attrs`**`: Object` + // : The attributes to add to the input element. + // **`value`**`: function(state) -> string` + // : The getter for the input value. + constructor(options) { + this.options = options || {}; + } + + // :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool} + // Renders the submenu. + render(view) { + const id = randHtmlId(); + const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {}) + const input = crel("input", inputAttrs); + const label = crel("label", {for: id}, this.options.label); + + const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, input); + + const update = (state) => { + input.value = this.options.value(state); + return true; + } + + return {dom: rowRap, update} + } + +} + +export default DialogInput; \ No newline at end of file diff --git a/resources/js/editor/menu/icons.js b/resources/js/editor/menu/icons.js index f6ac99d7e..022a0078b 100644 --- a/resources/js/editor/menu/icons.js +++ b/resources/js/editor/menu/icons.js @@ -109,6 +109,10 @@ export const icons = { width: 24, height: 24, path: "M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z" }, + close: { + width: 24, height: 24, + path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z", + } }; const SVG = "http://www.w3.org/2000/svg" diff --git a/resources/js/editor/menu/index.js b/resources/js/editor/menu/index.js index 115178bc8..290e6f6f0 100644 --- a/resources/js/editor/menu/index.js +++ b/resources/js/editor/menu/index.js @@ -4,10 +4,13 @@ import { } from "./menu" import {icons} from "./icons"; import ColorPickerGrid from "./ColorPickerGrid"; +import DialogBox from "./DialogBox"; import {toggleMark} from "prosemirror-commands"; import {menuBar} from "./menubar" import schema from "../schema"; import {removeMarks} from "../commands"; +import DialogForm from "./DialogForm"; +import DialogInput from "./DialogInput"; function cmdItem(cmd, options) { @@ -161,6 +164,37 @@ const utilities = [ }), ]; +function getMarkAttribute(markType, attribute) { + return function(state) { + const marks = state.selection.$head.marks(); + for (const mark of marks) { + if (mark.type === markType) { + return mark.attrs[attribute]; + } + } + + return null; + }; +} + +let box = new DialogBox([ + new DialogForm([ + new DialogInput({ + label: 'URL', + id: 'url', + value: getMarkAttribute(schema.marks.link, 'href'), + }), + new DialogInput({ + label: 'Title', + id: 'title', + value: getMarkAttribute(schema.marks.link, 'title'), + }) + ], { + canceler: () => box.close(), + action: (data) => console.log('submit', data), + }), +], {label: 'Insert Link', closer: () => {console.log('close')}}); + const menu = menuBar({ floating: false, content: [ @@ -172,6 +206,7 @@ const menu = menuBar({ lists, inserts, utilities, + [box] ], }); diff --git a/resources/js/editor/menu/menu-utils.js b/resources/js/editor/menu/menu-utils.js new file mode 100644 index 000000000..3410d8ebd --- /dev/null +++ b/resources/js/editor/menu/menu-utils.js @@ -0,0 +1,39 @@ +import crel from "crelt"; + +export const prefix = "ProseMirror-menu"; + +export 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)} +} + +export function renderItems(items, view) { + let rendered = [], updates = [] + for (let i = 0; i < items.length; i++) { + let {dom, update} = items[i].render(view) + rendered.push(dom); + updates.push(update) + } + return {dom: rendered, update: combineUpdates(updates, rendered)} +} + +export 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 + } +} + +export function randHtmlId() { + return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 9); +} \ No newline at end of file diff --git a/resources/js/editor/menu/menu.js b/resources/js/editor/menu/menu.js index 082264e7e..e962ed0a4 100644 --- a/resources/js/editor/menu/menu.js +++ b/resources/js/editor/menu/menu.js @@ -9,10 +9,10 @@ import crel from "crelt" import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands" import {undo, redo} from "prosemirror-history" import {setBlockAttr, insertBlockBefore} from "../commands"; +import {renderDropdownItems, combineUpdates} from "./menu-utils"; import {getIcon, icons} from "./icons" - -const prefix = "ProseMirror-menu" +import {prefix} from "./menu-utils"; // ::- An icon or label that, when clicked, executes a command. export class MenuItem { @@ -212,27 +212,6 @@ export class Dropdown { } } -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. diff --git a/resources/sass/_editor.scss b/resources/sass/_editor.scss index 463aaedb0..c2f93d4eb 100644 --- a/resources/sass/_editor.scss +++ b/resources/sass/_editor.scss @@ -369,4 +369,87 @@ img.ProseMirror-separator { height: 20px; border: 2px solid #FFF; display: block; +} + +.ProseMirror-menu-dialog-wrap { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.1); + z-index: 50; + display: grid; +} + +.ProseMirror-menu-dialog-title { + padding: $-xs $-s; + border-bottom: 1px solid #DDD; + font-weight: bold; + position: relative; + margin-bottom: $-xs; +} + +.ProseMirror-menu-dialog-footer { + padding: $-xs $-s; + border-top: 1px solid #DDD; + display: flex; + justify-content: end; + margin-top: $-xs; +} + +.ProseMirror-menu-dialog-title-close { + color: #FFF; + position: absolute; + top: $-xs + 2px; + right: $-s; + border-radius: 9px; + height: 18px; + width: 18px; + text-align: center; + line-height: 0; + vertical-align: top; + display: flex; + justify-content: center; + align-items: center; +} + +.ProseMirror-menu-dialog { + background-color: #FFF; + border: 1px solid #DDD; + border-radius: 3px; + box-shadow: $bs-large; + width: fit-content; + min-width: 300px; + min-height: 100px; + margin: auto; +} + +.ProseMirror-menu-dialog-button { + border: 1px solid #DDD; + padding: $-xs $-s; + color: #666; + min-width: 80px; + cursor: pointer; + &:hover { + background-color: #EEE; + } +} + +.ProseMirror-menu-dialog-button + .ProseMirror-menu-dialog-button { + margin-left: $-xs; +} + +.ProseMirror-menu-dialog-form-row { + display: grid; + grid-template-columns: 1fr 2fr; + align-items: center; + padding: $-xs 0; + label { + padding: 0 $-s; + font-size: .9rem; + } + input { + margin: 0 $-s; + } } \ No newline at end of file diff --git a/resources/views/editor-test.blade.php b/resources/views/editor-test.blade.php index df8fd4ad1..aef1a689a 100644 --- a/resources/views/editor-test.blade.php +++ b/resources/views/editor-test.blade.php @@ -16,6 +16,7 @@ Some Underlined content Lorem ipsum dolor sit amet.
Some striked content Lorem ipsum dolor sit amet.
Some Red Content Lorem ipsum dolor sit amet.
+ Some Linked Content Lorem ipsum dolor sit amet.

Logo