]> BookStack Code Mirror - bookstack/commitdiff
Started menu dialog support
authorDan Brown <redacted>
Fri, 14 Jan 2022 20:56:05 +0000 (20:56 +0000)
committerDan Brown <redacted>
Fri, 14 Jan 2022 20:56:05 +0000 (20:56 +0000)
TODO
resources/js/editor/menu/ColorPickerGrid.js
resources/js/editor/menu/DialogBox.js [new file with mode: 0644]
resources/js/editor/menu/DialogForm.js [new file with mode: 0644]
resources/js/editor/menu/DialogInput.js [new file with mode: 0644]
resources/js/editor/menu/icons.js
resources/js/editor/menu/index.js
resources/js/editor/menu/menu-utils.js [new file with mode: 0644]
resources/js/editor/menu/menu.js
resources/sass/_editor.scss
resources/views/editor-test.blade.php

diff --git a/TODO b/TODO
index f93f5c1f1080ce63ac4df998b13d8422f3aa39df..fbba22f5098ab874d6f421ad58f612291112ac84 100644 (file)
--- 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
 
index 91ea73317cefb4acaddd1c1565ab3f73f3efd11a..c5eacc3354605a06386b15846a1b6e91c2004624 100644 (file)
@@ -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 (file)
index 0000000..a9aa443
--- /dev/null
@@ -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 (file)
index 0000000..3827f1f
--- /dev/null
@@ -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 (file)
index 0000000..dabbb23
--- /dev/null
@@ -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
index f6ac99d7e89229c64d5ea05e6cbc554fe19d4336..022a0078b9552cef19347f67c72f5fe0475045bd 100644 (file)
@@ -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"
index 115178bc8489a089f3b2bb4c9567a34e1fb572a3..290e6f6f0c50e537f9156c4b7bd3e1fc14143412 100644 (file)
@@ -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 (file)
index 0000000..3410d8e
--- /dev/null
@@ -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
index 082264e7e18b3ecbf7020ed62dae749d52abbd6c..e962ed0a4d4544ca00591329c70966f78a42c916 100644 (file)
@@ -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.
index 463aaedb0c280dafbeaa6591bacd35823cdd3533..c2f93d4eb698b30804b54f328a815c48b70d7eab 100644 (file)
@@ -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
index df8fd4ad12bcf467e701ca520cc9e14d94fa84a9..aef1a689a0979227ac8ed9b001f5360767114e23 100644 (file)
@@ -16,6 +16,7 @@
                 Some <span style="text-decoration: underline">Underlined content</span> Lorem ipsum dolor sit amet. <br>
                 Some <span style="text-decoration: line-through;">striked content</span> Lorem ipsum dolor sit amet. <br>
                 Some <span style="color: red;">Red Content</span> Lorem ipsum dolor sit amet. <br>
+                Some <a href="https://cats.com" target="_blank" title="link A">Linked Content</a> Lorem ipsum dolor sit amet. <br>
             </p>
             <p><img src="/user_avatar.png" alt="Logo"></p>
             <ul>