Created
December 2, 2025 15:36
-
-
Save matthiask/64ea64b539d63d45ff71467752c2f307 to your computer and use it in GitHub Desktop.
ProseMirror/Tiptap extension for adding classes to nodes, marks and inline text
This file contains hidden or 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
| /** | |
| * ClassLoom Extension | |
| * | |
| * Adds CSS class management to ProseMirror nodes and marks. | |
| * Works with any node type (paragraphs, tables, lists, etc.) and any mark type (italic, bold, etc.). | |
| * | |
| * Features: | |
| * - Multiple class groups targeting different node/mark types | |
| * - Combinable groups (multiple classes from same group) | |
| * - Special "text" type: creates one separate mark per group (allows combining multiple text class groups) | |
| * - Priority control: higher priority marks interrupt lower priority ones when mixed | |
| * - Menu integration with active state tracking | |
| * | |
| * Usage: | |
| * ClassLoom.configure({ | |
| * groups: { | |
| * tableStyles: { | |
| * title: "Table Styles", | |
| * type: "table", // Any node type | |
| * classes: ["table--bordered", "table--striped"] | |
| * }, | |
| * emphasisStyles: { | |
| * title: "Emphasis Styles", | |
| * type: "italic", // Any mark type | |
| * classes: ["theme-color", "grey-color"] | |
| * }, | |
| * textColors: { | |
| * title: "Text Colors", | |
| * type: "text", // Special: creates new mark (one per group) | |
| * combinable: true, // Allow multiple classes from this group | |
| * priority: 102, // Higher priority interrupts lower priority marks | |
| * classes: [ | |
| * { className: "theme-color", title: "Theme Color" }, | |
| * { className: "grey-color", title: "Grey" } | |
| * ] | |
| * } | |
| * } | |
| * }) | |
| * | |
| * Class format: string or { className: string, title: string } | |
| * | |
| * The menu integration creates groups with a name of `classLoom:tableStyles`, `classLoom:emphasisStyles` and `classLoom:textColors` when using the configuration above. If you want the buttons or dropdowns to appear, add those classes to your Menu extension configuration. | |
| */ | |
| import { crel, Extension, Mark } from "django-prose-editor/editor" | |
| // Normalize class config to always have both className and title properties | |
| const cssClass = (c) => (typeof c === "string" ? { className: c, title: c } : c) | |
| // Walk up the document tree from the cursor position to find ancestor nodes | |
| const getSelectionNodes = (state, types) => { | |
| const { $from } = state.selection | |
| const nodes = [] | |
| for (let depth = $from.depth; depth > 0; --depth) { | |
| const node = $from.node(depth) | |
| if (types.includes(node.type.name)) { | |
| nodes.push({ | |
| typeName: node.type.name, | |
| node, | |
| depth, | |
| pos: $from.before(depth), | |
| }) | |
| } | |
| } | |
| return nodes | |
| } | |
| const getSelectionMarks = (state, types) => { | |
| const { $from } = state.selection | |
| const marks = [] | |
| for (const mark of $from.marks()) { | |
| if (types.includes(mark.type.name)) { | |
| marks.push({ typeName: mark.type.name, mark }) | |
| } | |
| } | |
| return marks | |
| } | |
| // Merge class changes into existing class string, preserving unrelated classes | |
| // Returns sorted space-separated string for consistent HTML output | |
| const determineClasses = (current, classes) => { | |
| const newClasses = new Set((current || "").split(/\s+/)) | |
| for (const [cssClass, include] of Object.entries(classes)) { | |
| if (include) { | |
| newClasses.add(cssClass) | |
| } else { | |
| newClasses.delete(cssClass) | |
| } | |
| } | |
| return [...newClasses].filter(Boolean).toSorted().join(" ") | |
| } | |
| // Filter class string to only include classes defined in this group | |
| // Returns null if no valid classes remain to avoid empty class attributes | |
| const validClasses = (className, classes) => { | |
| className = className?.trim() | |
| if (!className) return null | |
| className = className | |
| .split(/\s+/) | |
| .filter((c) => classes.includes(c)) | |
| .toSorted() | |
| .join(" ") | |
| return className || null | |
| } | |
| const isClassActive = (editor, type, className) => { | |
| if (editor.state.schema.nodes[type]) { | |
| for (const { node } of getSelectionNodes(editor.state, [type])) { | |
| return node.attrs.class?.split(/\s+/).includes(className) | |
| } | |
| } | |
| for (const { mark } of getSelectionMarks(editor.state, [type])) { | |
| return mark.attrs.class?.split(/\s+/).includes(className) | |
| } | |
| return false | |
| } | |
| export const ClassLoom = Extension.create({ | |
| name: "classLoom", | |
| addOptions() { | |
| return { | |
| groups: [], | |
| } | |
| }, | |
| addExtensions() { | |
| // Text classes need separate mark types to allow combining multiple text class groups | |
| return Object.entries(this.options.groups) | |
| .filter(([, group]) => group.type === "text") | |
| .map(([ident, group]) => { | |
| const classes = group.classes.map(cssClass).map((c) => c.className) | |
| return ClassLoomText.extend({ | |
| name: `classLoom:${ident}`, | |
| // Higher priority than strong/em to ensure proper class handling | |
| priority: group.priority || 101, | |
| }).configure({ classes, ident }) | |
| }) | |
| }, | |
| addGlobalAttributes() { | |
| // Block-level elements (paragraphs, headings, etc.) get class attributes | |
| // Text classes are handled separately via marks | |
| return [ | |
| { | |
| types: [ | |
| ...new Set( | |
| Object.values(this.options.groups) | |
| .filter((group) => group.type !== "text") | |
| .map((group) => group.type), | |
| ), | |
| ], | |
| attributes: { | |
| class: { | |
| default: null, | |
| parseHTML: (element) => { | |
| const className = element.className?.trim() | |
| if (!className) return null | |
| return className | |
| }, | |
| renderHTML: (attributes) => { | |
| if (!attributes.class) { | |
| return {} | |
| } | |
| return { | |
| class: attributes.class, | |
| } | |
| }, | |
| }, | |
| }, | |
| }, | |
| ] | |
| }, | |
| addCommands() { | |
| return { | |
| // Update classes on the relevant node/mark type, merging with existing classes | |
| updateClasses: | |
| (type, classes) => | |
| ({ commands, state }) => { | |
| if (state.schema.nodes[type]) { | |
| for (const { node, pos } of getSelectionNodes(state, [type])) { | |
| return commands.command(({ tr }) => { | |
| tr.setNodeAttribute( | |
| pos, | |
| "class", | |
| determineClasses(node.attrs.class, classes), | |
| ) | |
| return true | |
| }) | |
| } | |
| return false | |
| } | |
| for (const { mark } of getSelectionMarks(state, [type])) { | |
| commands.extendMarkRange(type) | |
| return commands.updateAttributes(type, { | |
| class: determineClasses(mark.attrs.class, classes), | |
| }) | |
| } | |
| }, | |
| } | |
| }, | |
| addMenuItems({ buttons, menu }) { | |
| for (const [ident, group] of Object.entries(this.options.groups)) { | |
| const groupIdent = `${this.name}:${ident}` | |
| const cssClasses = group.classes.map(cssClass) | |
| // Object to clear all classes in this group when applying "default" | |
| const clearAll = Object.fromEntries( | |
| cssClasses.map(({ className }) => [className, false]), | |
| ) | |
| menu.defineItem({ | |
| name: `${this.name}:${groupIdent}:default`, | |
| groups: [this.name, groupIdent, `${this.name}:default`], | |
| button: buttons.text(group.title), | |
| option: crel("p", { | |
| textContent: `${group.title}: default`, | |
| }), | |
| active(editor) { | |
| return cssClasses.every( | |
| (c) => !isClassActive(editor, group.type, c.className), | |
| ) | |
| }, | |
| command(editor) { | |
| if (group.type === "text") { | |
| editor.chain().focus()[`updateClasses:${ident}`](clearAll).run() | |
| } else { | |
| editor.chain().focus().updateClasses(group.type, clearAll).run() | |
| } | |
| }, | |
| }) | |
| for (const c of cssClasses) { | |
| menu.defineItem({ | |
| name: `${this.name}:${groupIdent}:${c.className}`, | |
| groups: [this.name, groupIdent], | |
| button: buttons.text(c.title), | |
| option: crel("p", { textContent: c.title }), | |
| active(editor) { | |
| if (group.type === "text") { | |
| // Text classes use marks, check all classLoom marks | |
| return !!editor.state.selection.$from | |
| .marks() | |
| .find( | |
| (mark) => | |
| mark.type.name.startsWith("classLoom:") && | |
| mark.attrs.class?.split(/\s+/)?.includes(c.className), | |
| ) | |
| } | |
| return isClassActive(editor, group.type, c.className) | |
| }, | |
| command(editor) { | |
| // Combinable groups preserve existing classes, others clear first | |
| const classes = group.combinable ? {} : clearAll | |
| if (group.type === "text") { | |
| editor | |
| .chain() | |
| .focus() | |
| [`updateClasses:${ident}`]({ | |
| ...classes, | |
| [c.className]: true, | |
| }) | |
| .run() | |
| return | |
| } | |
| // Toggle off if already active, otherwise apply | |
| editor | |
| .chain() | |
| .focus() | |
| .updateClasses( | |
| group.type, | |
| isClassActive(editor, group.type, c.className) | |
| ? { [c.className]: false } | |
| : { ...classes, [c.className]: true }, | |
| ) | |
| .run() | |
| }, | |
| }) | |
| } | |
| } | |
| }, | |
| }) | |
| const ClassLoomText = Mark.create({ | |
| addOptions() { | |
| return { classes: [], ident: null } | |
| }, | |
| addAttributes() { | |
| return { | |
| class: { | |
| default: null, | |
| parseHTML: (element) => | |
| validClasses(element.className, this.options.classes), | |
| renderHTML: (attributes) => | |
| attributes.class ? { class: attributes.class } : {}, | |
| }, | |
| } | |
| }, | |
| parseHTML() { | |
| return [ | |
| { | |
| tag: "span", | |
| // Non-consuming allows multiple marks to parse the same span | |
| consuming: false, | |
| getAttrs: (element) => | |
| validClasses(element.className, this.options.classes) ? {} : false, | |
| }, | |
| ] | |
| }, | |
| renderHTML({ HTMLAttributes }) { | |
| return ["span", HTMLAttributes, 0] | |
| }, | |
| addCommands() { | |
| const extensionName = this.name | |
| const definedClasses = this.options.classes | |
| return { | |
| [`updateClasses:${this.options.ident}`]: | |
| (classes) => | |
| ({ commands, state }) => { | |
| const type = state.schema.marks[extensionName] | |
| return commands.command(({ tr }) => { | |
| const { empty, ranges } = tr.selection | |
| // Don't apply to collapsed selections (avoid invisible marks) | |
| if (empty) return false | |
| ranges.forEach((range) => { | |
| const from = range.$from.pos | |
| const to = range.$to.pos | |
| tr.doc.nodesBetween(from, to, (node, pos) => { | |
| if (node.type.name === "text") { | |
| // Trim to selection boundaries to avoid modifying text outside selection | |
| const trimmedFrom = Math.max(pos, from) | |
| const trimmedTo = Math.min(pos + node.nodeSize, to) | |
| const existingMark = node.marks.find( | |
| (mark) => mark.type === type, | |
| ) | |
| const newClass = validClasses( | |
| determineClasses(existingMark?.attrs?.class, classes), | |
| definedClasses, | |
| ) | |
| console.debug({ node, newClass }) | |
| if (newClass) { | |
| tr.addMark( | |
| trimmedFrom, | |
| trimmedTo, | |
| type.create({ class: newClass }), | |
| ) | |
| } else { | |
| // Remove mark entirely if no valid classes remain | |
| tr.removeMark(trimmedFrom, trimmedTo, type) | |
| } | |
| } | |
| }) | |
| }) | |
| return true | |
| }) | |
| }, | |
| } | |
| }, | |
| }) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment