From 5d2459ec41ed944f724eed7f83156bd1beeb2a43 Mon Sep 17 00:00:00 2001 From: Thomas JUND Date: Thu, 19 May 2022 18:05:03 +0200 Subject: [PATCH] add bullet list (#59606) --- dist/css/godo.css | 35 +++++- dist/index.html | 17 +++ src-js/godo-additional-commands.mjs | 185 ++++++++++++++++++++++++++++ src-js/godo-additional-keymap.mjs | 14 ++- src-js/godo-menus-language.mjs | 12 +- src-js/godo-menus.mjs | 39 ++++-- src-js/godo-schemas.mjs | 32 ++++- src-js/godo.js | 6 +- 8 files changed, 318 insertions(+), 22 deletions(-) create mode 100644 src-js/godo-additional-commands.mjs diff --git a/dist/css/godo.css b/dist/css/godo.css index 5176239..b8d0259 100644 --- a/dist/css/godo.css +++ b/dist/css/godo.css @@ -15,8 +15,8 @@ .menuicons { font-size: 14px; position: relative; - color: #888; min-width: 2em; + display: inline-flex; } .menuicon, .menuicon:hover { padding: .16em .33em; @@ -40,7 +40,28 @@ position: sticky; top: 0; background-color: white; - border-bottom: 2px solid currentColor; +} +.godo-blocks-menu > .menuicons { + column-gap: .33em; +} +.godo-blocks-menu .menuicon { + color: #555; + background-color: white; + border: 1px solid #eee; +} +.godo-blocks-menu .menuicon:hover { + color: black; + border-color: #555; +} +.godo-blocks-menu .menuicon:disabled { + background-color: #eee; + color: #aaa; + opacity: 0.8; +} +.godo-blocks-menu .menuicon.active, +.godo-blocks-menu .menuicon:active { + color: #386EDE; + border-color: #386EDE; } /* Marks menu */ @@ -102,3 +123,13 @@ font-variant: small-caps; font-weight: 900; } + +/* Editor typo */ +div.godo .ProseMirror.godo--editor ul { + list-style-type: disc; + padding-left: 1em; + margin-bottom: 1em; +} +div.godo .ProseMirror.godo--editor ul li p { + margin: 0; +} diff --git a/dist/index.html b/dist/index.html index 74e43ea..f0ff9a3 100644 --- a/dist/index.html +++ b/dist/index.html @@ -70,6 +70,20 @@ + +

Lists

+

p, h1 -> h3, strong, em, ul

+
+ + + +
@@ -84,6 +98,9 @@ const test2 = new Godo(document.querySelector('.textarea-for-godo-2'), { schema: "headers" }); + const test3 = new Godo(document.querySelector('.textarea-for-godo-3'), { + schema: "list" + }); diff --git a/src-js/godo-additional-commands.mjs b/src-js/godo-additional-commands.mjs new file mode 100644 index 0000000..c121b56 --- /dev/null +++ b/src-js/godo-additional-commands.mjs @@ -0,0 +1,185 @@ +import {findWrapping, liftTarget, canSplit, ReplaceAroundStep} from "prosemirror-transform" +import {Slice, Fragment, NodeRange} from "prosemirror-model" + + +// wrapInList, doWrapInList, splitListItem, liftToOuterList, liftOutOfList, sinkListItem +// adapted from https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.js + +// :: (NodeType, ?Object) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Returns a command function that wraps the selection in a list with +// the given type an attributes. If `dispatch` is null, only return a +// value to indicate whether this is possible, but don't actually +// perform the change. +export function wrapInList(listType, attrs) { + return function(state, dispatch) { + let {$from, $to} = state.selection + let range = $from.blockRange($to), doJoin = false, outerRange = range + if (!range) return false + // This is at the top of an existing list item + if (range.depth >= 2 && $from.node(range.depth - 1).type.compatibleContent(listType) && range.startIndex == 0) { + // Don't do anything if this is the top of the list + if ($from.index(range.depth - 1) == 0) return false + let $insert = state.doc.resolve(range.start - 2) + outerRange = new NodeRange($insert, $insert, range.depth) + if (range.endIndex < range.parent.childCount) + range = new NodeRange($from, state.doc.resolve($to.end(range.depth)), range.depth) + doJoin = true + } + let wrap = findWrapping(outerRange, listType, attrs, range) + if (!wrap) return false + if (dispatch) dispatch(doWrapInList(state.tr, range, wrap, doJoin, listType).scrollIntoView()) + return true + } +} + +function doWrapInList(tr, range, wrappers, joinBefore, listType) { + let content = Fragment.empty + for (let i = wrappers.length - 1; i >= 0; i--) + content = Fragment.from(wrappers[i].type.create(wrappers[i].attrs, content)) + + tr.step(new ReplaceAroundStep(range.start - (joinBefore ? 2 : 0), range.end, range.start, range.end, + new Slice(content, 0, 0), wrappers.length, true)) + + let found = 0 + for (let i = 0; i < wrappers.length; i++) if (wrappers[i].type == listType) found = i + 1 + let splitDepth = wrappers.length - found + + let splitPos = range.start + wrappers.length - (joinBefore ? 2 : 0), parent = range.parent + for (let i = range.startIndex, e = range.endIndex, first = true; i < e; i++, first = false) { + if (!first && canSplit(tr.doc, splitPos, splitDepth)) { + tr.split(splitPos, splitDepth) + splitPos += 2 * splitDepth + } + splitPos += parent.child(i).nodeSize + } + return tr +} + +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Build a command that splits a non-empty textblock at the top level +// of a list item by also splitting that list item. +export function splitListItem(itemType) { + return function(state, dispatch) { + let {$from, $to, node} = state.selection + if ((node && node.isBlock) || $from.depth < 2 || !$from.sameParent($to)) return false + let grandParent = $from.node(-1) + if (grandParent.type != itemType) return false + if ($from.parent.content.size == 0 && $from.node(-1).childCount == $from.indexAfter(-1)) { + // In an empty block. If this is a nested list, the wrapping + // list item should be split. Otherwise, bail out and let next + // command handle lifting. + if ($from.depth == 2 || $from.node(-3).type != itemType || + $from.index(-2) != $from.node(-2).childCount - 1) return false + if (dispatch) { + let wrap = Fragment.empty + let depthBefore = $from.index(-1) ? 1 : $from.index(-2) ? 2 : 3 + // Build a fragment containing empty versions of the structure + // from the outer list item to the parent node of the cursor + for (let d = $from.depth - depthBefore; d >= $from.depth - 3; d--) + wrap = Fragment.from($from.node(d).copy(wrap)) + let depthAfter = $from.indexAfter(-1) < $from.node(-2).childCount ? 1 + : $from.indexAfter(-2) < $from.node(-3).childCount ? 2 : 3 + // Add a second list item with an empty default start node + wrap = wrap.append(Fragment.from(itemType.createAndFill())) + let start = $from.before($from.depth - (depthBefore - 1)) + let tr = state.tr.replace(start, $from.after(-depthAfter), new Slice(wrap, 4 - depthBefore, 0)) + let sel = -1 + tr.doc.nodesBetween(start, tr.doc.content.size, (node, pos) => { + if (sel > -1) return false + if (node.isTextblock && node.content.size == 0) sel = pos + 1 + }) + if (sel > -1) tr.setSelection(state.selection.constructor.near(tr.doc.resolve(sel))) + dispatch(tr.scrollIntoView()) + } + return true + } + let nextType = $to.pos == $from.end() ? grandParent.contentMatchAt(0).defaultType : null + let tr = state.tr.delete($from.pos, $to.pos) + let types = nextType && [null, {type: nextType}] + if (!canSplit(tr.doc, $from.pos, 2, types)) return false + if (dispatch) dispatch(tr.split($from.pos, 2, types).scrollIntoView()) + return true + } +} + +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Create a command to lift the list item around the selection up into +// a wrapping list. +export function liftListItem(itemType) { + return function(state, dispatch) { + let {$from, $to} = state.selection + let range = $from.blockRange($to, node => node.childCount && node.firstChild.type == itemType) + if (!range) return false + if (!dispatch) return true + if ($from.node(range.depth - 1).type == itemType) // Inside a parent list + return liftToOuterList(state, dispatch, itemType, range) + else // Outer list node + return liftOutOfList(state, dispatch, range) + } +} + +function liftToOuterList(state, dispatch, itemType, range) { + let tr = state.tr, end = range.end, endOfList = range.$to.end(range.depth) + if (end < endOfList) { + // There are siblings after the lifted items, which must become + // children of the last item + tr.step(new ReplaceAroundStep(end - 1, endOfList, end, endOfList, + new Slice(Fragment.from(itemType.create(null, range.parent.copy())), 1, 0), 1, true)) + range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(endOfList), range.depth) + } + dispatch(tr.lift(range, liftTarget(range)).scrollIntoView()) + return true +} + +function liftOutOfList(state, dispatch, range) { + let tr = state.tr, list = range.parent + // Merge the list items into a single big item + for (let pos = range.end, i = range.endIndex - 1, e = range.startIndex; i > e; i--) { + pos -= list.child(i).nodeSize + tr.delete(pos - 1, pos + 1) + } + let $start = tr.doc.resolve(range.start), item = $start.nodeAfter + if (tr.mapping.map(range.end) != range.start + $start.nodeAfter.nodeSize) return false + let atStart = range.startIndex == 0, atEnd = range.endIndex == list.childCount + let parent = $start.node(-1), indexBefore = $start.index(-1) + if (!parent.canReplace(indexBefore + (atStart ? 0 : 1), indexBefore + 1, + item.content.append(atEnd ? Fragment.empty : Fragment.from(list)))) + return false + let start = $start.pos, end = start + item.nodeSize + // Strip off the surrounding list. At the sides where we're not at + // the end of the list, the existing list is closed. At sides where + // this is the end, it is overwritten to its end. + tr.step(new ReplaceAroundStep(start - (atStart ? 1 : 0), end + (atEnd ? 1 : 0), start + 1, end - 1, + new Slice((atStart ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))) + .append(atEnd ? Fragment.empty : Fragment.from(list.copy(Fragment.empty))), + atStart ? 0 : 1, atEnd ? 0 : 1), atStart ? 0 : 1)) + dispatch(tr.scrollIntoView()) + return true +} + +// :: (NodeType) → (state: EditorState, dispatch: ?(tr: Transaction)) → bool +// Create a command to sink the list item around the selection down +// into an inner list. +export function sinkListItem(itemType) { + return function(state, dispatch) { + let {$from, $to} = state.selection + let range = $from.blockRange($to, node => node.childCount && node.firstChild.type == itemType) + if (!range) return false + let startIndex = range.startIndex + if (startIndex == 0) return false + let parent = range.parent, nodeBefore = parent.child(startIndex - 1) + if (nodeBefore.type != itemType) return false + + if (dispatch) { + let nestedBefore = nodeBefore.lastChild && nodeBefore.lastChild.type == parent.type + let inner = Fragment.from(nestedBefore ? itemType.create() : null) + let slice = new Slice(Fragment.from(itemType.create(null, Fragment.from(parent.type.create(null, inner)))), + nestedBefore ? 3 : 1, 0) + let before = range.start, after = range.end + dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after, + before, after, slice, 1, true)) + .scrollIntoView()) + } + return true + } +} diff --git a/src-js/godo-additional-keymap.mjs b/src-js/godo-additional-keymap.mjs index d0f4e2d..8682034 100644 --- a/src-js/godo-additional-keymap.mjs +++ b/src-js/godo-additional-keymap.mjs @@ -1,5 +1,6 @@ import {wrapIn, setBlockType, chainCommands, toggleMark, exitCode, joinUp, joinDown, lift, selectParentNode} from "prosemirror-commands" +import {splitListItem, liftListItem} from "./godo-additional-commands.mjs" import {undo, redo} from "prosemirror-history" const mac = typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : false @@ -16,8 +17,6 @@ const mac = typeof navigator != "undefined" ? /Mac/.test(navigator.platform) : f // * **Ctrl-Shift-1** to **Ctrl-Shift-Digit6** for making the current // textblock a heading of the corresponding level // * **Ctrl-Shift-Backslash** to make the current textblock a code block -// * **Ctrl-Shift-8** to wrap the selection in an ordered list -// * **Ctrl-Shift-9** to wrap the selection in a bullet list // * **Ctrl->** to wrap the selection in a block quote // * **Enter** to split a non-empty textblock in a list item while at // the same time splitting the list item @@ -43,7 +42,6 @@ export function buildKeymap(schema, mapKeys, options) { keys[key] = cmd } - bind("Mod-z", undo) bind("Shift-Mod-z", redo) if (!mac) bind("Mod-y", redo) @@ -66,6 +64,12 @@ export function buildKeymap(schema, mapKeys, options) { if (type = schema.nodes.blockquote) bind("Ctrl->", wrapIn(type)) + + if (type = schema.nodes.list_item) { + bind("Enter", splitListItem(type)) + bind("Mod-[", liftListItem(type)) + } + if (type = schema.nodes.hard_break) { let br = type, cmd = chainCommands(exitCode, (state, dispatch) => { dispatch(state.tr.replaceSelectionWith(br.create()).scrollIntoView()) @@ -78,13 +82,16 @@ export function buildKeymap(schema, mapKeys, options) { if (type = schema.nodes.paragraph) bind("Shift-Ctrl-0", setBlockType(type)) + if (type = schema.nodes.code_block) bind("Shift-Ctrl-\\", setBlockType(type)) + if (type = schema.nodes.heading) { options.heading_levels.forEach( (level, index) => { bind(`Shift-Ctrl-${index+1}`, setBlockType(type, {level: level})) }); } + if (type = schema.nodes.horizontal_rule) { let hr = type bind("Mod-_", (state, dispatch) => { @@ -94,5 +101,4 @@ export function buildKeymap(schema, mapKeys, options) { } return keys - } diff --git a/src-js/godo-menus-language.mjs b/src-js/godo-menus-language.mjs index d4de524..bf0c3b2 100644 --- a/src-js/godo-menus-language.mjs +++ b/src-js/godo-menus-language.mjs @@ -68,12 +68,22 @@ const languageContent = { "p": { en: { icon: "p", - text: "paragraph", + text: "paragraph" }, fr: { icon: "p", text: "paragraphe" } + }, + "ul": { + en: { + icon: "•", + text: "bullet list" + }, + fr: { + icon: "•", + text: "Liste à puce" + } } } diff --git a/src-js/godo-menus.mjs b/src-js/godo-menus.mjs index e779555..18bf458 100644 --- a/src-js/godo-menus.mjs +++ b/src-js/godo-menus.mjs @@ -1,4 +1,5 @@ -import {toggleMark, setBlockType, wrapIn} from "prosemirror-commands"; +import {toggleMark, setBlockType, wrapIn, chainCommands} from "prosemirror-commands"; +import {wrapInList, liftListItem} from "./godo-additional-commands.mjs"; import getLanguage from "./godo-menus-language.mjs"; class Menu { @@ -56,9 +57,13 @@ class Menu { update_blocks(view, state) { for (const item in this.items) { - const {dom, run} = this.items[item]; - const is_active = !run(state, null, view); - const is_disabled = is_active; + const {dom, run, type, active, options} = this.items[item]; + const active_default = () => { + let {$from, to} = state.selection; + return to <= $from.end() && $from.parent.hasMarkup(type, options) + }; + const is_active = (active) ? active(state) : active_default(); + const is_disabled = !run(state, null, view);; this.set_menu_item(dom, is_active, is_disabled); } } @@ -120,7 +125,10 @@ function linkItem( type ) { function setHeader(type, level, icon_id) { return { - run: setBlockType(type, {level}), dom: icon(icon_id) + run: setBlockType(type, {level}), + dom: icon(icon_id), + type, + options: {level} } } @@ -150,9 +158,26 @@ function blocks( schema, h_levels ) { }); } if (type = schema.nodes.paragraph) { - i.setP = {run: setBlockType(type), dom: icon("p")}; + i.setP = {run: setBlockType(type), dom: icon("p"), type}; + } + if (type = schema.nodes.bullet_list) { + i.setList = { + run: chainCommands(wrapInList(type), liftListItem(schema.nodes.list_item)), + dom: icon("ul"), + type, + active(state) { + let {$from, $to} = state.selection + let range = $from.blockRange($to) + switch (range.parent.type) { + case type: + case schema.nodes.list_item: + return true; + default: + return false; + } + } + }; } - menu.items = i; return menu; } diff --git a/src-js/godo-schemas.mjs b/src-js/godo-schemas.mjs index 6c240fc..e8d6974 100644 --- a/src-js/godo-schemas.mjs +++ b/src-js/godo-schemas.mjs @@ -81,7 +81,25 @@ const headersSchema = { } } -// HEADERS +// LISTS +const listSchema = { + nodes: { + // A bullet list node spec, represented in the DOM as `