js: separate list node in a component and test it (#69407)
This commit is contained in:
parent
83da849bb9
commit
052fb4ef04
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>',
|
||||
)
|
||||
})
|
||||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue