1
0
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:
Dan Brown
2022-01-14 20:56:05 +00:00
parent c013d7e549
commit 7703face52
11 changed files with 328 additions and 25 deletions

8
TODO
View File

@ -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

View 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 {

View 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;

View 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;

View 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;

View 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"

View 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]
],
});

View 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);
}

View 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.

View 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;
}
}

View 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>