js: separate link mark in a component and test it (#69407)

This commit is contained in:
Corentin Sechet 2023-03-16 18:59:57 +01:00 committed by Corentin Sechet
parent 693c85baa9
commit 83da849bb9
8 changed files with 220 additions and 108 deletions

106
src-js/components/link.mjs Normal file
View File

@ -0,0 +1,106 @@
import {toggleMark} from 'prosemirror-commands'
import {icon} from '../utils.mjs'
import { openDialog, TextField } from '../godo-dialog.mjs'
import getLanguage from '../godo-menus-language.mjs'
export default () => ({
marks: {
// :: MarkSpec A link mark. `href` attribute.
// Rendered and parsed as an `<a>` element.
link: {
attrs: {href: {}},
inclusive: false,
parseDOM: [{
tag: 'a[href]',
getAttrs: (node) => ({href: node.href}),
}],
toDOM: (node) => ['a', {href: node.attrs.href}, 0],
},
},
marksMenu (schema) {
const link = schema.marks.link
return {
toggleLink: {
run: editOrToggleLink(link),
dom: icon('a'),
type: link,
},
}
},
})
// Edit or Toggle Link. For range only.
// create link or Edit link if link exist in select range
// or remove link if multiple links exists in select range
function editOrToggleLink (linkType) {
return function (state, dispatch, view) {
let tr = state.tr
let $sel = state.selection
let range = $sel.ranges[0]
if (!range && $sel.ranges.length > 1) return
function setLink (linkNodes, attrs) {
linkNodes.forEach(linkNode => {
tr.addMark(linkNode.from, linkNode.to, linkType.create(attrs))
})
dispatch(tr)
}
function unsetLink (linkNodes) {
linkNodes.forEach(linkNode => {
tr.removeMark(linkNode.from, linkNode.to, linkType)
})
dispatch(tr)
}
if (dispatch) {
const {from, to} = $sel
let linkNodes = []
let linkTargets = new Set()
state.doc.nodesBetween(from, to, (node, pos) => {
const linkMarks = node.marks.filter(m => linkType === m.type)
if (linkMarks.length) {
linkNodes.push({
node,
from: pos,
to: pos + node.nodeSize,
})
linkMarks.forEach(m => linkTargets.add(m.attrs.href))
}
})
if (linkTargets.size > 1) {
unsetLink(linkNodes)
} else {
openDialog({
title: getLanguage('a').dialog.title,
fields: {
href: new TextField({
name: 'href',
label: getLanguage('a').dialog.href,
required: true,
value: linkTargets.values().next().value || '', // get first element of set or empty string
}),
},
callback (dialogValue, attrs) {
switch (dialogValue) {
case 'validate':
if (linkNodes.length) setLink(linkNodes, attrs)
else toggleMark(linkType, attrs)(state, dispatch)
break
case 'remove':
unsetLink(linkNodes)
break
}
view.focus()
},
})
}
} else {
return toggleMark(linkType)(state, null)
}
return true
}
}

View File

@ -1,8 +1,5 @@
import {findWrapping, liftTarget, canSplit, ReplaceAroundStep} from 'prosemirror-transform'
import {Slice, Fragment, NodeRange} from 'prosemirror-model'
import {toggleMark} from 'prosemirror-commands'
import {openDialog, TextField} from './godo-dialog.mjs'
import getLanguage from './godo-menus-language.mjs'
// wrapInList, doWrapInList, splitListItem, liftToOuterList, liftOutOfList, sinkListItem
@ -191,77 +188,3 @@ export function sinkListItem (itemType) {
return true
}
}
// Edit or Toggle Link. For range only.
// create link or Edit link if link exist in select range
// or remove link if multiple links exists in select range
export function editOrToggleLink (linkType) {
return function (state, dispatch, view) {
let tr = state.tr
let $sel = state.selection
let range = $sel.ranges[0]
if (!range && $sel.ranges.length > 1) return
function setLink (linkNodes, attrs) {
linkNodes.forEach(linkNode => {
tr.addMark(linkNode.from, linkNode.to, linkType.create(attrs))
})
dispatch(tr)
}
function unsetLink (linkNodes) {
linkNodes.forEach(linkNode => {
tr.removeMark(linkNode.from, linkNode.to, linkType)
})
dispatch(tr)
}
if (dispatch) {
const {from, to} = $sel
let linkNodes = []
let linkTargets = new Set()
state.doc.nodesBetween(from, to, (node, pos) => {
const linkMarks = node.marks.filter(m => linkType === m.type)
if (linkMarks.length) {
linkNodes.push({
node,
from: pos,
to: pos + node.nodeSize,
})
linkMarks.forEach(m => linkTargets.add(m.attrs.href))
}
})
if (linkTargets.size > 1) {
unsetLink(linkNodes)
} else {
openDialog({
title: getLanguage('a').dialog.title,
fields: {
href: new TextField({
name: 'href',
label: getLanguage('a').dialog.href,
required: true,
value: linkTargets.values().next().value || '', // get first element of set or empty string
}),
},
callback (dialogValue, attrs) {
switch (dialogValue) {
case 'validate':
if (linkNodes.length) setLink(linkNodes, attrs)
else toggleMark(linkType, attrs)(state, dispatch)
break
case 'remove':
unsetLink(linkNodes)
break
}
view.focus()
},
})
}
} else {
return toggleMark(linkType)(state, null)
}
return true
}
}

View File

@ -1,5 +1,5 @@
import {chainCommands} from 'prosemirror-commands'
import {wrapInList, liftListItem, editOrToggleLink} from './godo-additional-commands.mjs'
import {wrapInList, liftListItem} from './godo-additional-commands.mjs'
import {icon} from './utils.mjs'
class Menu {
@ -150,18 +150,15 @@ function blocks (components, schema) {
function marks (components, schema) {
let menu = {
name: 'marks',
items: {},
}
let type; let i = {}
for (const component of components) {
if (component.marksMenu) {
Object.assign(i, component.marksMenu(schema))
Object.assign(menu.items, component.marksMenu(schema))
}
}
if ((type = schema.marks.link)) { i.toggleLink = {run: editOrToggleLink(type), dom: icon('a'), type} }
menu.items = i
return menu
}

View File

@ -7,20 +7,6 @@
// To reuse elements from this schema, extend or read from its
// `spec.nodes` and `spec.marks` [properties](#model.Schema.spec).
// BASIC
const basicSchemaContent = {
marks: {
// :: MarkSpec A link mark. `href` attribute.
// Rendered and parsed as an `<a>` element.
link: {
attrs: {href: {}},
inclusive: false,
parseDOM: [{tag: 'a[href]', getAttrs (node) { return {href: node.href} }}],
toDOM (node) { return ['a', {href: node.attrs.href}, 0] },
},
},
}
// LISTS
const listSchemaContent = {
nodes: {
@ -41,16 +27,10 @@ const listSchemaContent = {
},
}
const basicSchema = basicSchemaContent
const fullSchema = {
nodes: Object.assign({},
basicSchemaContent.nodes,
listSchemaContent.nodes,
),
marks: Object.assign({},
basicSchemaContent.marks,
),
}
export { basicSchema, fullSchema }
export { fullSchema }

View File

@ -6,13 +6,14 @@ import {baseKeymap} from 'prosemirror-commands'
import {DOMParser, DOMSerializer} from 'prosemirror-model'
import {buildKeymap} from './godo-additional-keymap.mjs'
import {basicSchema, fullSchema} from './godo-schemas.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'
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'
function menuPlugin (menu) {
return new Plugin({
@ -61,14 +62,14 @@ export default class Godo {
switch (this.options.schema) {
case 'basic':
displayBlocksMenu = false
return basicSchema
return {}
case 'full':
default:
return fullSchema
}
})()
const componentsDefinitions = [base, fontMarks]
const componentsDefinitions = [base, fontMarks, link]
if (this.options.schema === 'full') {
componentsDefinitions.push(heading)
}

View File

@ -1,4 +1,6 @@
env:
node: true
globals:
fail: readonly # Jasmine's 'fail', Jasmine is used by jest

View File

@ -0,0 +1,98 @@
import {test, expect, vi} from 'vitest'
import {Node} from 'prosemirror-model'
import {loadSchema} from '../../src-js/utils'
import {parse, serialize, TestState} from '../helpers'
import base from '../../src-js/components/base'
const openDialogMock = vi.fn()
vi.mock('../../src-js/godo-dialog.mjs', () => ({
TextField: vi.fn(),
openDialog: openDialogMock,
}))
test('parse dom', async () => {
const {default: link} = await import('../../src-js/components/link')
const schema = loadSchema([base(), link()])
const node = parse(schema, '<p><a href="https://entrouvert.com">CLIQUE</a></p>')
expect(node.toJSON()).toEqual({
type: 'doc',
content: [{
type: 'paragraph',
content: [{
type: 'text',
text: 'CLIQUE',
marks: [{
type: 'link',
attrs: { href: 'https://entrouvert.com/' },
}],
}],
}],
})
})
test('serialize to dom', async () => {
const {default: link} = await import('../../src-js/components/link')
const schema = loadSchema([base(), link()])
const doc = Node.fromJSON(schema, {
type: 'doc',
content: [{
type: 'paragraph',
content: [{
type: 'text',
text: 'CLIQUE',
marks: [{
type: 'link',
attrs: { href: 'https://entrouvert.com' },
}],
}],
}],
})
expect(serialize(schema, doc)).toBe('<p><a href="https://entrouvert.com">CLIQUE</a></p>')
})
test('marks menu actions', async () => {
const {default: link} = await import('../../src-js/components/link')
const state = new TestState([base(), link()], '<p>CLIQUE SAPRISTI</p>')
function mockValidate (url) {
openDialogMock.mockImplementation(({callback}) => {
callback('validate', {href: url})
})
}
function mockRemove () {
openDialogMock.mockImplementation(({callback}) => {
callback('remove')
})
}
state.select('CLIQUE')
mockValidate('https://clique.com')
state.clickMarksMenu('toggleLink')
expect(state.html).toBe('<p><a href="https://clique.com">CLIQUE</a> SAPRISTI</p>')
state.select('CLIQUE')
mockRemove()
state.clickMarksMenu('toggleLink')
expect(state.html).toBe('<p>CLIQUE SAPRISTI</p>')
state.select('CLIQUE')
mockValidate('https://clique.com')
state.clickMarksMenu('toggleLink')
state.select('SAPRISTI')
mockValidate('https://clique-encore.com')
state.clickMarksMenu('toggleLink')
expect(state.html).toBe(
'<p><a href="https://clique.com">CLIQUE</a> '
+ '<a href="https://clique-encore.com">SAPRISTI</a></p>',
)
state.select('LIQUE SAPRI')
openDialogMock.mockImplementation(() => fail())
state.clickMarksMenu('toggleLink')
expect(state.html).toBe('<p>CLIQUE SAPRISTI</p>')
})

View File

@ -1,6 +1,7 @@
import {EditorState, Selection, TextSelection} from 'prosemirror-state'
import {DOMParser, DOMSerializer} from 'prosemirror-model'
import {history} from 'prosemirror-history'
import {vi} from 'vitest'
import {loadSchema} from '../src-js/utils'
import {buildKeymap} from '../src-js/godo-additional-keymap.mjs'
@ -22,6 +23,10 @@ export function serialize (schema, fragment) {
return div.innerHTML
}
const viewMock = {
focus: vi.fn(),
}
export class TestState {
constructor (components, initialHtml) {
const schema = loadSchema(components)
@ -85,5 +90,5 @@ export class TestState {
clickBlocksMenu (name) { this.blocksMenu[name].run(this.state, tr => this.apply(tr)) }
clickMarksMenu (name) { this.marksMenu[name].run(this.state, tr => this.apply(tr)) }
clickMarksMenu (name) { this.marksMenu[name].run(this.state, tr => this.apply(tr), viewMock) }
}