js: separate list node in a component and test it (#69407)

This commit is contained in:
Corentin Sechet 2023-03-16 21:15:19 +01:00 committed by Corentin Sechet
parent 83da849bb9
commit 052fb4ef04
8 changed files with 207 additions and 147 deletions

View File

@ -1,6 +1,60 @@
import { chainCommands } from 'prosemirror-commands'
import {findWrapping, liftTarget, canSplit, ReplaceAroundStep} from 'prosemirror-transform'
import {Slice, Fragment, NodeRange} from 'prosemirror-model'
import {icon} from '../utils.mjs'
export default () => ({
nodes: {
// A bullet list node spec, represented in the DOM as `<ul>`.
bulletList: {
content: 'listItem+',
group: 'block',
parseDOM: [{tag: 'ul'}],
toDOM () { return ['ul', 0] },
},
// A list item (`<li>`) spec.
listItem: {
content: 'paragraph+',
parseDOM: [{tag: 'li'}],
toDOM () { return ['li', 0] },
defining: true,
},
},
keymap (schema) {
const listItem = schema.nodes.listItem
return {
'Enter': splitListItem(listItem),
'Mod-[': liftListItem(listItem),
}
},
blocksMenu (schema) {
const bulletList = schema.nodes.bulletList
const listItem = schema.nodes.listItem
return {
setList: {
run: chainCommands(wrapInList(bulletList), liftListItem(listItem)),
dom: icon('ul'),
type: bulletList,
active (state) {
let {$from, $to} = state.selection
let range = $from.blockRange($to)
if (range === undefined) {
return false
}
switch (range.parent.type) {
case bulletList:
case listItem:
return true
default:
return false
}
},
},
}
},
})
// wrapInList, doWrapInList, splitListItem, liftToOuterList, liftOutOfList, sinkListItem
// adapted from https://github.com/ProseMirror/prosemirror-schema-list/blob/master/src/schema-list.js
@ -10,7 +64,7 @@ import {Slice, Fragment, NodeRange} from 'prosemirror-model'
// 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) {
function wrapInList (listType, attrs) {
return function (state, dispatch) {
let {$from, $to} = state.selection
let range = $from.blockRange($to); let doJoin = false; let outerRange = range
@ -61,7 +115,7 @@ function doWrapInList (tr, range, wrappers, joinBefore, listType) {
// :: (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) {
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
@ -109,7 +163,7 @@ export function splitListItem (itemType) {
// :: (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) {
function liftListItem (itemType) {
return function (state, dispatch) {
let {$from, $to} = state.selection
let range = $from.blockRange($to, node => node.childCount && node.firstChild.type === itemType)
@ -160,31 +214,3 @@ function liftOutOfList (state, dispatch, range) {
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; let 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; let after = range.end
dispatch(state.tr.step(new ReplaceAroundStep(before - (nestedBefore ? 3 : 1), after,
before, after, slice, 1, true))
.scrollIntoView())
}
return true
}
}

View File

@ -1,6 +1,3 @@
import {wrapIn, setBlockType, toggleMark } from 'prosemirror-commands'
import {splitListItem, liftListItem} from './godo-additional-commands.mjs'
// :: (Schema, ?Object) → Object
// Inspect the given schema looking for marks and nodes from the
// basic schema, and if found, add key bindings related to them.
@ -26,33 +23,8 @@ import {splitListItem, liftListItem} from './godo-additional-commands.mjs'
// You can suppress or map these bindings by passing a `mapKeys`
// argument, which maps key names (say `"Mod-B"` to either `false`, to
// remove the binding, or a new key name string.
export function buildKeymap (components, schema, mapKeys) {
let keys = {}; let type
function bind (key, cmd) {
if (mapKeys) {
let mapped = mapKeys[key]
if (mapped === false) return
if (mapped) key = mapped
}
keys[key] = cmd
}
if ((type = schema.marks.code)) bind('Mod-`', toggleMark(type))
if ((type = schema.nodes.blockquote)) bind('Ctrl->', wrapIn(type))
if ((type = schema.nodes.listItem)) {
bind('Enter', splitListItem(type))
bind('Mod-[', liftListItem(type))
}
if ((type = schema.nodes.code_block)) bind('Shift-Ctrl-\\', setBlockType(type))
if ((type = schema.nodes.horizontal_rule)) {
let hr = type
bind('Mod-_', (state, dispatch) => {
dispatch(state.tr.replaceSelectionWith(hr.create()).scrollIntoView())
return true
})
}
export function buildKeymap (components, schema) {
let keys = {}
for (const component of components) {
if (component.keymap) {

View File

@ -1,7 +1,3 @@
import {chainCommands} from 'prosemirror-commands'
import {wrapInList, liftListItem} from './godo-additional-commands.mjs'
import {icon} from './utils.mjs'
class Menu {
constructor (menu, editorView) {
this.items = menu.items
@ -113,37 +109,15 @@ class Menu {
function blocks (components, schema) {
let menu = {
name: 'blocks',
items: {},
}
let type; let i = {}
for (const component of components) {
if (component.blocksMenu) {
Object.assign(i, component.blocksMenu(schema))
Object.assign(menu.items, component.blocksMenu(schema))
}
}
if ((type = schema.nodes.bulletList)) {
i.setList = {
run: chainCommands(wrapInList(type), liftListItem(schema.nodes.listItem)),
dom: icon('ul'),
type,
active (state) {
let {$from, $to} = state.selection
let range = $from.blockRange($to)
if (range === undefined) {
return false
}
switch (range.parent.type) {
case type:
case schema.nodes.listItem:
return true
default:
return false
}
},
}
}
menu.items = i
return menu
}

View File

@ -1,36 +0,0 @@
// :: Schema
// This schema roughly corresponds to the document schema used by
// [CommonMark](http://commonmark.org/), minus the list elements,
// which are defined in the [`prosemirror-schema-list`](#schema-list)
// module.
//
// To reuse elements from this schema, extend or read from its
// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec).
// LISTS
const listSchemaContent = {
nodes: {
// A bullet list node spec, represented in the DOM as `<ul>`.
bulletList: {
content: 'listItem+',
group: 'block',
parseDOM: [{tag: 'ul'}],
toDOM () { return ['ul', 0] },
},
// A list item (`<li>`) spec.
listItem: {
content: 'paragraph+',
parseDOM: [{tag: 'li'}],
toDOM () { return ['li', 0] },
defining: true,
},
},
}
const fullSchema = {
nodes: Object.assign({},
listSchemaContent.nodes,
),
}
export { fullSchema }

View File

@ -6,7 +6,6 @@ import {baseKeymap} from 'prosemirror-commands'
import {DOMParser, DOMSerializer} from 'prosemirror-model'
import {buildKeymap} from './godo-additional-keymap.mjs'
import {fullSchema} from './godo-schemas.mjs'
import {Menu, blocks, marks} from './godo-menus.mjs'
import {dialog} from './godo-dialog.mjs'
import {loadSchema} from './utils.mjs'
@ -14,6 +13,7 @@ import base from './components/base.mjs'
import fontMarks from './components/font-marks.mjs'
import heading from './components/heading.mjs'
import link from './components/link.mjs'
import list from './components/list.mjs'
function menuPlugin (menu) {
return new Plugin({
@ -48,7 +48,6 @@ export default class Godo {
this.textarea = textarea
this.textarea.hidden = true
this.textarea.style.display = 'none'
let displayBlocksMenu = true
this.defaultOptions = {
schema: 'full',
@ -58,24 +57,13 @@ export default class Godo {
this.options = Object.assign(this.defaultOptions, options)
const baseSchema = (() => {
switch (this.options.schema) {
case 'basic':
displayBlocksMenu = false
return {}
case 'full':
default:
return fullSchema
}
})()
const componentsDefinitions = [base, fontMarks, link]
if (this.options.schema === 'full') {
componentsDefinitions.push(heading)
componentsDefinitions.push(heading, list)
}
const components = componentsDefinitions.map(c => c(options))
this.schema = loadSchema(components, baseSchema)
this.schema = loadSchema(components)
this.editorWrapper = document.createElement('div')
this.editorWrapper.className = 'godo'
@ -92,7 +80,7 @@ export default class Godo {
this.marksMenu,
]
if (displayBlocksMenu && !textarea.readOnly) pluginsList.push(this.blocksMenu)
if (this.options.schema !== 'basic' && !textarea.readOnly) pluginsList.push(this.blocksMenu)
if (options.instantUpdate) pluginsList.push(changeEventPlugin(this))
const stateConfig = () => {

View File

@ -1,18 +1,13 @@
import {Schema} from 'prosemirror-model'
import getLanguage from './godo-menus-language.mjs'
export function loadSchema (components, baseSchema) {
export function loadSchema (components) {
const schemaSpec = {nodes: {}, marks: {}}
for (const component of components) {
Object.assign(schemaSpec.nodes, component.nodes)
Object.assign(schemaSpec.marks, component.marks)
}
if (baseSchema !== undefined) {
Object.assign(schemaSpec.nodes, baseSchema.nodes)
Object.assign(schemaSpec.marks, baseSchema.marks)
}
return new Schema(schemaSpec)
}

View File

@ -0,0 +1,134 @@
import {Node} from 'prosemirror-model'
import {test, expect } from 'vitest'
import {loadSchema} from '../../src-js/utils'
import {parse, serialize, TestState} from '../helpers'
import base from '../../src-js/components/base'
import list from '../../src-js/components/list'
test('parse dom', () => {
const schema = loadSchema([base(), list()])
const node = parse(schema, `
<ul>
<li><p>OK</p></li>
<li><p>NICKEL</p></li>
</ul>
`)
expect(node.toJSON()).toEqual({
type: 'doc',
content: [{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{
type: 'paragraph',
content: [{type: 'text', text: 'OK' }],
}],
},
{
type: 'listItem',
content: [{
type: 'paragraph',
content: [{type: 'text', text: 'NICKEL' }],
}],
},
],
}],
})
})
test('serialize to dom', () => {
const schema = loadSchema([base(), list()])
const doc = Node.fromJSON(schema, {
type: 'doc',
content: [{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [{
type: 'paragraph',
content: [{type: 'text', text: 'OK' }],
}],
},
{
type: 'listItem',
content: [{
type: 'paragraph',
content: [{type: 'text', text: 'NICKEL' }],
}],
},
],
}],
})
expect(serialize(schema, doc)).toBe(
'<ul>'
+ '<li><p>OK</p></li>'
+ '<li><p>NICKEL</p></li>'
+ '</ul>',
)
})
test('lift list item', () => {
const state = new TestState([base(), list()], `
<ul>
<li><p>OK</p></li>
<li><p>NICKEL</p></li>
</ul>
`)
state.cursorAfter('OK')
state.key('Mod-[')
expect(state.html).toBe(
'<p>OK</p>'
+ '<ul>'
+ '<li><p>NICKEL</p></li>'
+ '</ul>',
)
})
test('split list item', () => {
const state = new TestState([base(), list()], `
<ul>
<li><p>OK</p></li>
</ul>
`)
state.cursorAfter('OK')
state.key('Enter')
state.type('NICKEL')
expect(state.html).toBe(
'<ul>'
+ '<li><p>OK</p></li>'
+ '<li><p>NICKEL</p></li>'
+ '</ul>',
)
})
test('set list menu', () => {
const state = new TestState([base(), list()], `
<p>OK</p>
<p>NICKEL</p>
`)
state.select('KNICK')
state.clickBlocksMenu('setList')
expect(state.html).toBe(
'<ul>'
+ '<li><p>OK</p></li>'
+ '<li><p>NICKEL</p></li>'
+ '</ul>',
)
state.cursorAfter('NICK')
state.clickBlocksMenu('setList')
expect(state.html).toBe(
'<ul>'
+ '<li><p>OK</p></li>'
+ '</ul>'
+ '<p>NICKEL</p>',
)
})

View File

@ -86,9 +86,16 @@ export class TestState {
this.selectTextOffset(textOffset, textOffset + text.length)
}
cursorAfter (text) {
const textOffset = this.state.doc.textContent.indexOf(text)
if (textOffset === -1) throw new Error('Can\'t find given text in document')
this.selectTextOffset(textOffset + text.length)
}
key (keyScheme) { this.keymap[keyScheme](this.state, tr => this.apply(tr)) }
clickBlocksMenu (name) { this.blocksMenu[name].run(this.state, tr => this.apply(tr)) }
clickMarksMenu (name) { this.marksMenu[name].run(this.state, tr => this.apply(tr), viewMock) }
}