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
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import crel from "crelt"
|
||||
const prefix = "ProseMirror-menu"
|
||||
import {prefix} from "./menu-utils";
|
||||
import {toggleMark} from "prosemirror-commands";
|
||||
|
||||
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,
|
||||
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"
|
||||
|
@ -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]
|
||||
],
|
||||
});
|
||||
|
||||
|
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 {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.
|
||||
|
@ -370,3 +370,86 @@ img.ProseMirror-separator {
|
||||
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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
Reference in New Issue
Block a user