How to get current active mark, problem with toggleMark and how to create a toolbar

So I am trying to create this basic functionality but I feel, the documentation here is not up to par.

While I did manage to invent a way to create my own toolbar, I think it’s quite hacky how it works. I guess creating a plugin is the way to go, but the integration to the current editor DOM is done by appending the plugin’s node to it? Well here’s my current implementation:

// ToolbarPlugin.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { Plugin } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'

import { Toolbar } from './Toolbar'

export const toolbarPlugin = new Plugin({
  view(editorView: EditorView) {
    return new ToolbarPluginView(editorView)
  }
})

interface IPluginProps {
  editorView: EditorView
}

class ToolbarPluginView<IPluginProps> {
  dom: HTMLElement
  ref: React.RefObject<any>

  view: EditorView

  constructor(editorView: EditorView) {
    this.view = editorView

    this.ref = React.createRef()
    this.dom = document.createElement('span')
    this.view.dom.parentNode!.appendChild(this.dom)
    this.dom.classList.add('plugin__dom')

    ReactDOM.render(
      <Toolbar ref={this.ref} editorView={this.view}/>,
      this.dom
    )
  }

  update() {
    console.log('update ToolbarPluginView')
  }

  destroy() {
    ReactDOM.unmountComponentAtNode(this.dom)
  }
}
// Toolbar.tsx
import React from 'react'
import { toggleMark, setBlockType, wrapIn } from 'prosemirror-commands'
import { schema } from 'prosemirror-schema-basic'
import { EditorView } from 'prosemirror-view'
import styled, { StyledComponentClass } from 'styled-components'

import {
  MdFormatBold, MdFormatItalic,
} from 'react-icons/md'

import { ITheme } from '../../../types/theme'

import { MarkButton } from './MarkButton'

interface IProps {
  editorView: EditorView
}
interface IState {
}
export class Toolbar extends React.Component<IProps, IState> {

  shouldComponentUpdate(nextProps: IProps, nextState: IState) {
    console.log('shouldComponentUpdate Toolbar')
    return true
  }

  render() {
    const { editorView } = this.props
    return (
      <Container>
        <h2>Toolbar</h2>
        <MarksRow>
          <MarkButton
            active={toggleMark(schema.marks.strong)(editorView.state, undefined)}
            name="bold"
            icon={<MdFormatBold size={24}/>}
            onClick={() => {
              debugger
              toggleMark(schema.marks.strong)(editorView.state, editorView.dispatch)
              editorView.focus()
            }}
          />
          <MarkButton
            active={toggleMark(schema.marks.em)(editorView.state, undefined)}
            name="italic"
            icon={<MdFormatItalic size={24}/>}
            onClick={() => {
              toggleMark(schema.marks.em)(editorView.state, editorView.dispatch)
              editorView.focus()
            }}
          />
        </MarksRow>
      </Container>
    )
  }
}

const Container = styled.div`
  background: #7ab4ff;
  & > h2 {
    margin: 0 0 5px 0;
  }
`
const MarksRow = styled.div`
  display: flex;
`
// MarkButton.tsx
import React from 'react'
import styled from 'styled-components'

interface IProps {
  className?: string
  active: boolean
  name: string
  icon: React.ReactNode
  onClick: () => void
}
function MarkButtonEl(props: IProps) {
  const { className, active, name, icon, onClick } = props
  return (
    <Button
      className={className}
      active={active}
      name={name}
      onClick={onClick}
    >
      <SvgWrapper>
        {icon}
      </SvgWrapper>
    </Button>
  )
}

interface IButtonProps { active: boolean }
const SvgWrapper = styled.span`
  display: flex;
`
const Button = styled.button`
  background: ${(props: IButtonProps) => props.active ? '#f0f8ff' : ''};
  border: ${(props: IButtonProps) => props.active ? '1px solid #5a6ecd' : '1px solid transparent'};
  cursor: pointer;
  display: flex;
  margin-right: 5px;
  padding: 1px;
  &:hover {
    background: #f0f8ff;
    opacity: 0.6;
  }
`
export const MarkButton = styled(MarkButtonEl)``

There I am creating a ToolbarPluginView, that I append with its contents to the editorview with this.view.dom.parentNode!.appendChild(this.dom). Which I think is a bit magical way of doing it. Also it always appends it underneath the editor, so I guess I should add some specific slot for this toolbar. Or just create the toolbar using a regular React component, passing along the editorView. What is best practise in this case?

As a side note, I think it’s fun, that ProseMirror is low-level with its DOM manipulation, but the complexity of managing DOM with its quirks and interactions is not something I would want to do by hand. It would be nice to have examples of basic PM functionality using different JS frameworks.

Anyway, moving on to the real problems. I did not find out how to get the currently active marks. I think this should be pretty basic information, but I couldn’t find it. This let active = command(this.editorView.state, null, this.editorView) did not work. Also if I assumed correctly, I should also manually call the Toolbar to update either on every transaction or by first computing have the marks changed? The calling of editorView function such as toggleMark(schema.marks.strong)(editorView.state, undefined) in all MarkButtons is probably bad practise. Or just store the currently active marks somewhere and pass them down to the Toolbar?

I think there’s also a bug in the toggleMark-method, as if I activate both marks I can’t toggle either of them back. Text stays bolded and italic.

Third problem I have are the inconsistencies in the API. In the example menu the commands are executed as such: command(this.editorView.state, null, this.editorView). This, according to my typings, is not allowed. There’s no third argument and the second is either dispatch or undefined. I’ve found these inconsistencies to be quite widespread and eg in the schema examples there are never checks for the type of the node in getAttrs(node: string | Node). It’s just assumed it’s always either a Node or string, somehow. I don’t know how. Oral tradition?

I can provide an example if required.

It’s not quite clear what you mean by active marks. state.storedMarks? The marks at a given position?

I can’t reproduce this on the website example.

Commands that need access to the view state are allowed to take a third argument. Types specified as optional with a ? in the docs don’t distinguish between null and undefined. And no, the API doesn’t check the types it gets. That’s not practical in JavaScript. The docs specify the types each function accepts, and the caller is expected to pass the right thing. You can sarcastically call that “oral tradition” if you want, but that’s just how the JavaScript ecosystem works.

2 Likes

Hey, thanks for replying so quickly. I ended up creating my own method for getting the marks, and I do not know what is the proper term for “active marks”. Whatever the functionality is called when eg bolded icon gets active when you select text which includes bold. Or your cursor is either next to the mark or you have applied the toggleMark at the cursor. By the way, when you execute toggleMark at cursor position where is the mark stored, in storedMarks? That I’ve not covered with this method.

import { EditorState, Selection, Transaction, NodeSelection, TextSelection } from 'prosemirror-state'

import { schema } from '../schema'

const getNodeBeforeCursor = (selection: Selection) =>
  selection instanceof TextSelection ? selection.$cursor!.nodeBefore : undefined

export function selectionMarks(state: EditorState) {
  const { $from, $to } = state.selection
  const availableMarks = schema.marks
  const availableMarksLength = Object.keys(availableMarks).length
  const currentMarks = [] as string[]
  // No selection, pick the current marks from the text before the cursor
  if ($from.pos === $to.pos) {
    const nodeBefore = getNodeBeforeCursor(state.selection)
    if (nodeBefore) {
      currentMarks.push(...nodeBefore.marks.map(m => m.type.name))
    }
    return currentMarks
  }
  state.doc.nodesBetween($from.pos, $to.pos, (node) => {
    if (availableMarksLength === currentMarks.length) {
      return
    }
    if (node.marks.length !== 0) {
      for (let i = 0; i < node.marks.length; i += 1) {
        const nodeType = node.marks[i].type.name
        if (currentMarks.indexOf(nodeType) === -1) {
          currentMarks.push(nodeType)
        }
      }
    }
  })
  return currentMarks
}

which did seem to work, but I’m not sure if it’s the correct way of doing this. The marks() method only gets the marks for a single position and doesn’t accept a range eg a text selection range?

I added this computation to the ToolbarPluginView’s update method like so:

  update() {
    const newMarks = selectionMarks(this.view.state)
    const equalMarks = newMarks.length === this.currentMarks.length &&
      this.currentMarks.every(m => newMarks.includes(m))
    if (!equalMarks) {
      this.updateMarks(newMarks)
    }
    console.log('update ToolbarPluginView')
  }

but now the problem is how do I notify the React component that its props have changed. I tried using basic mobx observables but didn’t work in the first try. I’ll come back to it later. And I’ll add the toolbar to a public repo, once I get the marks working.

And well that might be so, but using the TypeScript definitions it’s agonizing to not be able to copy-paste code without having to add those missing checks et cetera. Here the typings describe the method simply:

export function toggleMark<S extends Schema = any>(
  markType: MarkType<S>,
  attrs?: { [key: string]: any }
): (state: EditorState<S>, dispatch?: (tr: Transaction<S>) => void) => boolean;

And do you mean it’s unpractical to check types in eg getAttrs? Is the typing here incorrect or is it just the developer’s duty to know which one it receives? If the typings are not to be followed this would just make the whole system unsound.

Also, about the toolbar implementation as a whole. Is a plugin how it’s supposed to be created? Or could you just pass the editorView to say a React component and be done with it? I mean perhaps you could then have the toolbar React-component self-subscribing itself as a plugin to the editorView, mixing them both together. I just want to get rid off the this.view.dom.parentNode!.appendChild(this.dom) somehow, as it’s not implicit from the DOM/JSX structure that the toolbar is inserted next to the editor.