ProseMirror schema and utils used for my custom editor implementation at letsken.com
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Schema } from 'prosemirror-model' | |
const pDOM = ['p', 0] | |
const blockquoteDOM = ['blockquote', 0] | |
const preDOM = ['pre', ['code', 0]] | |
const brDOM = ['br'] | |
const titleDOM = ['h1', 0] | |
const olDOM = ['ol', 0] | |
const ulDOM = ['ul', 0] | |
const liDOM = ['li', 0] | |
const emDOM = ['em', 0] | |
const strongDOM = ['strong', 0] | |
const codeDOM = ['code', 0] | |
// :: Object | |
// [Specs](#model.NodeSpec) for the nodes defined in this schema. | |
export const nodes = { | |
// :: NodeSpec The top level document node. | |
doc: { | |
content: 'title block+' | |
}, | |
title: { | |
content: 'inline*', | |
marks: '', | |
parseDOM: [{ tag: 'h1' }], | |
toDOM () { return titleDOM } | |
}, | |
// :: NodeSpec A plain paragraph textblock. Represented in the DOM | |
// as a `<p>` element. | |
paragraph: { | |
content: 'inline*', | |
group: 'block', | |
parseDOM: [{ tag: 'p' }], | |
toDOM () { return pDOM } | |
}, | |
// :: NodeSpec | |
// An ordered list [node spec](#model.NodeSpec). Has a single | |
// attribute, `order`, which determines the number at which the list | |
// starts counting, and defaults to 1. Represented as an `<ol>` | |
// element. | |
orderedList: { | |
content: 'listItem+', | |
group: 'block', | |
attrs: { order: { default: 1 } }, | |
parseDOM: [{ | |
tag: 'ol', | |
getAttrs (dom) { | |
return { order: dom.hasAttribute('start') ? +dom.getAttribute('start') : 1 } | |
} | |
}], | |
toDOM (node) { | |
return node.attrs.order === 1 ? olDOM : ['ol', { start: node.attrs.order }, 0] | |
} | |
}, | |
// :: NodeSpec | |
// A bullet list node spec, represented in the DOM as `<ul>`. | |
bulletList: { | |
content: 'listItem+', | |
group: 'block', | |
parseDOM: [{ tag: 'ul' }], | |
toDOM () { return ulDOM } | |
}, | |
// :: NodeSpec | |
// A list item (`<li>`) spec. | |
listItem: { | |
content: 'paragraph+', // only allow one or more paragraphs | |
// content: 'paragraph (orderedList | bulletList | paragraph)*', | |
parseDOM: [{ tag: 'li' }], | |
toDOM () { return liDOM }, | |
defining: true | |
}, | |
// :: NodeSpec A blockquote (`<blockquote>`) wrapping one or more blocks. | |
blockquote: { | |
content: 'paragraph+', | |
group: 'block', | |
defining: true, | |
parseDOM: [{ tag: 'blockquote' }], | |
toDOM () { return blockquoteDOM } | |
}, | |
// :: NodeSpec A horizontal rule (`<hr>`). | |
horizontalRule: { | |
attrs: { id: { default: '' } }, | |
group: 'block', | |
parseDOM: [{ | |
tag: 'hr', | |
getAttrs (dom) { | |
return { id: dom.getAttribute('id') } | |
} | |
}], | |
toDOM (node) { | |
return ['hr', { id: node.attrs.id }] | |
} | |
}, | |
// :: NodeSpec A heading textblock, with a `level` attribute that | |
// should hold the number 1 to 6. Parsed and serialized as `<h1>` to | |
// `<h6>` elements. | |
heading: { | |
attrs: { level: { default: 1 }, id: { default: '' } }, | |
content: 'inline*', | |
marks: 'comment', | |
group: 'block', | |
defining: true, | |
parseDOM: [ | |
{ | |
tag: 'h2', | |
getAttrs (dom) { | |
return { level: 1, id: dom.getAttribute('id') } | |
} | |
}, | |
{ | |
tag: 'h3', | |
getAttrs (dom) { | |
return { level: 2, id: dom.getAttribute('id') } | |
} | |
}, | |
{ | |
tag: 'h4', | |
getAttrs (dom) { | |
return { level: 3, id: dom.getAttribute('id') } | |
} | |
} | |
], | |
toDOM (node) { | |
return ['h' + (parseInt(node.attrs.level) + 1), { id: node.attrs.id }, 0] | |
} | |
}, | |
// :: NodeSpec A code listing. Disallows marks or non-text inline | |
// nodes by default. Represented as a `<pre>` element with a | |
// `<code>` element inside of it. | |
codeBlock: { | |
content: 'text*', | |
marks: '', | |
group: 'block', | |
code: true, | |
defining: true, | |
parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }], | |
toDOM () { return preDOM } | |
}, | |
// :: NodeSpec The text node. | |
text: { | |
group: 'inline' | |
}, | |
// :: NodeSpec An inline image (`<img>`) node. Supports `src`, | |
// `alt`, and `href` attributes. The latter two default to the empty | |
// string. | |
image: { | |
// inline: true, | |
attrs: { | |
s3Path: {}, | |
width: {}, | |
height: {} | |
}, | |
group: 'block', | |
draggable: true, | |
parseDOM: [{ | |
tag: 'img[data-s3-path]', | |
getAttrs (dom) { | |
return { | |
s3Path: dom.getAttribute('data-s3-path'), | |
width: dom.getAttribute('data-width'), | |
height: dom.getAttribute('data-height') | |
} | |
} | |
}], | |
toDOM (node) { | |
const { s3Path, width, height } = node.attrs | |
return ['img', { | |
'data-s3-path': s3Path, | |
'data-width': width, | |
'data-height': height | |
}] | |
} | |
}, | |
// :: NodeSpec A video (`<video>`) node. | |
video: { | |
attrs: { | |
muxPlaybackId: {}, | |
muxAssetId: {}, | |
width: {}, | |
height: {} | |
}, | |
group: 'block', | |
draggable: false, | |
parseDOM: [{ | |
tag: 'video', | |
getAttrs (dom) { | |
return { | |
muxPlaybackId: dom.getAttribute('data-mux-playback-id'), | |
muxAssetId: dom.getAttribute('data-mux-asset-id'), | |
width: dom.getAttribute('data-width'), | |
height: dom.getAttribute('data-height') | |
} | |
} | |
}], | |
toDOM (node) { | |
const { muxAssetId, muxPlaybackId, width, height } = node.attrs | |
return ['video', { | |
'data-mux-playback-id': muxPlaybackId, | |
'data-mux-asset-id': muxAssetId, | |
'data-width': width, | |
'data-height': height | |
}] | |
} | |
}, | |
// :: NodeSpec A hard line break, represented in the DOM as `<br>`. | |
hardBreak: { | |
inline: true, | |
group: 'inline', | |
selectable: false, | |
parseDOM: [{ tag: 'br' }], | |
toDOM () { return brDOM } | |
} | |
} | |
// :: Object [Specs](#model.MarkSpec) for the marks in the schema. | |
export const marks = { | |
// :: MarkSpec A link. Has `href` and `title` attributes. `title` | |
// defaults to the empty string. Rendered and parsed as an `<a>` | |
// element. | |
link: { | |
attrs: { | |
href: {}, | |
title: { default: null } | |
}, | |
inclusive: false, | |
parseDOM: [{ | |
tag: 'a[href]', | |
getAttrs (dom) { | |
return { href: dom.getAttribute('href'), title: dom.getAttribute('title') } | |
} | |
}], | |
toDOM (node) { const { href, title } = node.attrs; return ['a', { href, title }, 0] } | |
}, | |
comment: { | |
attrs: { | |
comment: {} | |
}, | |
inclusive: false, | |
parseDOM: [{ | |
tag: 'mark', | |
getAttrs (dom) { | |
return { comment: dom.getAttribute('data-comment') } | |
} | |
}], | |
toDOM (node) { const { comment } = node.attrs; return ['mark', { 'data-comment': comment }, 0] } | |
}, | |
// :: MarkSpec An emphasis mark. Rendered as an `<em>` element. | |
// Has parse rules that also match `<i>` and `font-style: italic`. | |
em: { | |
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], | |
toDOM () { return emDOM } | |
}, | |
// :: MarkSpec A strong mark. Rendered as `<strong>`, parse rules | |
// also match `<b>` and `font-weight: bold`. | |
strong: { | |
parseDOM: [ | |
{ tag: 'strong' }, | |
// This works around a Google Docs misbehavior where | |
// pasted content will be inexplicably wrapped in `<b>` | |
// tags with a font-weight normal. | |
{ tag: 'b', getAttrs: node => node.style.fontWeight !== 'normal' && null }, | |
{ style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }], | |
toDOM () { return strongDOM } | |
}, | |
// :: MarkSpec Code font mark. Represented as a `<code>` element. | |
code: { | |
parseDOM: [{ tag: 'code' }], | |
toDOM () { return codeDOM } | |
} | |
} | |
// :: 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). | |
export const schema = new Schema({ nodes, marks }) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export function markActive (type) { | |
return function (state) { | |
const { from, $from, to, empty } = state.selection | |
if (empty) return type.isInSet(state.storedMarks || $from.marks()) | |
else return state.doc.rangeHasMark(from, to, type) | |
} | |
} | |
export function canInsert (state, nodeType) { | |
const $from = state.selection.$from | |
for (let d = $from.depth; d >= 0; d--) { | |
const index = $from.index(d) | |
if ($from.node(d).canReplaceWith(index, index, nodeType)) return true | |
} | |
return false | |
} | |
export function markApplies (doc, ranges, type) { | |
for (let i = 0; i < ranges.length; i++) { | |
const { $from, $to } = ranges[i] | |
let can = $from.depth === 0 ? doc.type.allowsMarkType(type) : false | |
doc.nodesBetween($from.pos, $to.pos, node => { | |
if (can) return false | |
can = node.inlineContent && node.type.allowsMarkType(type) | |
}) | |
if (can) return true | |
} | |
return false | |
} | |
// Returns true when cursor (collapsed or not) is inside a link | |
export function linkActive (type) { | |
return function (state) { | |
const { from, to } = state.selection | |
return state.doc.rangeHasMark(from, to, type) | |
} | |
} | |
export function blockTypeActive (type, attrs) { | |
return function (state) { | |
// HACK: we fill in the id attribute if present, so the comparison works | |
const dynAttrs = Object.assign({}, attrs) | |
const { $from, to, node } = state.selection | |
if (node) { | |
if (node.attrs.id) { | |
dynAttrs.id = node.attrs.id | |
} | |
return node.hasMarkup(type, dynAttrs) | |
} | |
if ($from.parent && $from.parent.attrs.id) { | |
dynAttrs.id = $from.parent.attrs.id | |
} | |
const result = to <= $from.end() && $from.parent.hasMarkup(type, dynAttrs) | |
return result | |
} | |
} | |
// Returns the first mark found for a given type (e.g. link) | |
// TODO: currently this doesn't detect the case where a link has just one character | |
export function getMarkAtCurrentSelection (type) { | |
return function (state) { | |
const { $from } = state.selection | |
return $from.marks().find(m => m.type === type) | |
} | |
} | |
export function markExtend ($start, mark) { | |
let startIndex = $start.index() | |
let endIndex = $start.indexAfter() | |
while (startIndex > 0 && mark.isInSet($start.parent.child(startIndex - 1).marks)) { | |
startIndex-- | |
} | |
while (endIndex < $start.parent.childCount && mark.isInSet($start.parent.child(endIndex).marks)) { | |
endIndex++ | |
} | |
let startPos = $start.start() | |
let endPos = startPos | |
for (let i = 0; i < endIndex; i++) { | |
const size = $start.parent.child(i).nodeSize | |
if (i < startIndex) startPos += size | |
endPos += size | |
} | |
return { from: startPos, to: endPos } | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment