mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-07-28 17:02:04 +03:00
Started menu dialog support
This commit is contained in:
8
TODO
8
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
|
### Features
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import crel from "crelt"
|
import crel from "crelt"
|
||||||
const prefix = "ProseMirror-menu"
|
import {prefix} from "./menu-utils";
|
||||||
import {toggleMark} from "prosemirror-commands";
|
import {toggleMark} from "prosemirror-commands";
|
||||||
|
|
||||||
class ColorPickerGrid {
|
class ColorPickerGrid {
|
||||||
|
63
resources/js/editor/menu/DialogBox.js
Normal file
63
resources/js/editor/menu/DialogBox.js
Normal file
@ -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;
|
51
resources/js/editor/menu/DialogForm.js
Normal file
51
resources/js/editor/menu/DialogForm.js
Normal file
@ -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;
|
42
resources/js/editor/menu/DialogInput.js
Normal file
42
resources/js/editor/menu/DialogInput.js
Normal file
@ -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;
|
@ -109,6 +109,10 @@ export const icons = {
|
|||||||
width: 24, height: 24,
|
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"
|
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"
|
const SVG = "http://www.w3.org/2000/svg"
|
||||||
|
@ -4,10 +4,13 @@ import {
|
|||||||
} from "./menu"
|
} from "./menu"
|
||||||
import {icons} from "./icons";
|
import {icons} from "./icons";
|
||||||
import ColorPickerGrid from "./ColorPickerGrid";
|
import ColorPickerGrid from "./ColorPickerGrid";
|
||||||
|
import DialogBox from "./DialogBox";
|
||||||
import {toggleMark} from "prosemirror-commands";
|
import {toggleMark} from "prosemirror-commands";
|
||||||
import {menuBar} from "./menubar"
|
import {menuBar} from "./menubar"
|
||||||
import schema from "../schema";
|
import schema from "../schema";
|
||||||
import {removeMarks} from "../commands";
|
import {removeMarks} from "../commands";
|
||||||
|
import DialogForm from "./DialogForm";
|
||||||
|
import DialogInput from "./DialogInput";
|
||||||
|
|
||||||
|
|
||||||
function cmdItem(cmd, options) {
|
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({
|
const menu = menuBar({
|
||||||
floating: false,
|
floating: false,
|
||||||
content: [
|
content: [
|
||||||
@ -172,6 +206,7 @@ const menu = menuBar({
|
|||||||
lists,
|
lists,
|
||||||
inserts,
|
inserts,
|
||||||
utilities,
|
utilities,
|
||||||
|
[box]
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
39
resources/js/editor/menu/menu-utils.js
Normal file
39
resources/js/editor/menu/menu-utils.js
Normal file
@ -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);
|
||||||
|
}
|
@ -9,10 +9,10 @@ import crel from "crelt"
|
|||||||
import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
|
import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
|
||||||
import {undo, redo} from "prosemirror-history"
|
import {undo, redo} from "prosemirror-history"
|
||||||
import {setBlockAttr, insertBlockBefore} from "../commands";
|
import {setBlockAttr, insertBlockBefore} from "../commands";
|
||||||
|
import {renderDropdownItems, combineUpdates} from "./menu-utils";
|
||||||
|
|
||||||
import {getIcon, icons} from "./icons"
|
import {getIcon, icons} from "./icons"
|
||||||
|
import {prefix} from "./menu-utils";
|
||||||
const prefix = "ProseMirror-menu"
|
|
||||||
|
|
||||||
// ::- An icon or label that, when clicked, executes a command.
|
// ::- An icon or label that, when clicked, executes a command.
|
||||||
export class MenuItem {
|
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
|
// ::- Represents a submenu wrapping a group of elements that start
|
||||||
// hidden and expand to the right when hovered over or tapped.
|
// hidden and expand to the right when hovered over or tapped.
|
||||||
|
@ -369,4 +369,87 @@ img.ProseMirror-separator {
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
border: 2px solid #FFF;
|
border: 2px solid #FFF;
|
||||||
display: block;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
@ -16,6 +16,7 @@
|
|||||||
Some <span style="text-decoration: underline">Underlined content</span> Lorem ipsum dolor sit amet. <br>
|
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="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 <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>
|
||||||
<p><img src="/user_avatar.png" alt="Logo"></p>
|
<p><img src="/user_avatar.png" alt="Logo"></p>
|
||||||
<ul>
|
<ul>
|
||||||
|
Reference in New Issue
Block a user