Skip to content

Instantly share code, notes, and snippets.

@michael
Created January 14, 2023 17:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save michael/15b8a524f0e65f03bceccf4729d586c5 to your computer and use it in GitHub Desktop.
Save michael/15b8a524f0e65f03bceccf4729d586c5 to your computer and use it in GitHub Desktop.
ProseMirror schema and utils used for my custom editor implementation at letsken.com
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 })
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