Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support mark view #100

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions e2e/src/createEditorView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,41 @@ import 'prosemirror-menu/style/menu.css'

import { exampleSetup } from 'prosemirror-example-setup'
import { keymap } from 'prosemirror-keymap'
import { DOMParser } from 'prosemirror-model'
import { schema } from 'prosemirror-schema-basic'
import { DOMParser, Schema } from 'prosemirror-model'
import { schema as schema_base } from 'prosemirror-schema-basic'
import type { Plugin } from 'prosemirror-state'
import { EditorState } from 'prosemirror-state'
import type { NodeViewConstructor } from 'prosemirror-view'
import type {
MarkViewConstructor,
NodeViewConstructor,
} from 'prosemirror-view'
import { EditorView } from 'prosemirror-view'

export function createEditorView(element: HTMLElement | ShadowRoot, nodeViews: Record<string, NodeViewConstructor>, plugins: Plugin[]) {
const spec = schema_base.spec
const emSpec = spec.marks.get('em')

if (emSpec) {
emSpec.toDOM = () => {
return [
'em',
['span', { class: 'em_before token' }, 'em_before'],
['span', { class: 'em_text token' }, 0],
['span', { class: 'em_after token' }, 'em_after'],
]
}
}
else {
throw new Error('unable to find em in the schema')
}

const schema = new Schema(spec)

export function createEditorView(
element: HTMLElement | ShadowRoot,
nodeViews: Record<string, NodeViewConstructor>,
markViews: Record<string, MarkViewConstructor>,
plugins: Plugin[],
) {
const content = document.querySelector('#content')
if (!content)
throw new Error('Content element not found')
Expand Down Expand Up @@ -48,5 +75,6 @@ export function createEditorView(element: HTMLElement | ShadowRoot, nodeViews: R
],
}),
nodeViews,
markViews,
})
}
12 changes: 10 additions & 2 deletions e2e/src/react/components/Editor.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import './Editor.css'

import { useNodeViewFactory, usePluginViewFactory, useWidgetViewFactory } from '@prosemirror-adapter/react'
import { useMarkViewFactory, useNodeViewFactory, usePluginViewFactory, useWidgetViewFactory } from '@prosemirror-adapter/react'
import type { EditorView } from 'prosemirror-view'
import { DecorationSet } from 'prosemirror-view'
import type { FC } from 'react'
Expand All @@ -12,10 +12,12 @@ import { Hashes } from './Hashes'
import { Heading } from './Heading'
import { Paragraph } from './Paragraph'
import { Size } from './Size'
import { Link } from './Link'

export const Editor: FC = () => {
const viewRef = useRef<EditorView>()
const nodeViewFactory = useNodeViewFactory()
const markViewFactory = useMarkViewFactory()
const pluginViewFactory = usePluginViewFactory()
const widgetViewFactory = useWidgetViewFactory()
const editorRef = useRef<HTMLDivElement>(null)
Expand All @@ -42,6 +44,12 @@ export const Editor: FC = () => {
heading: nodeViewFactory({
component: Heading,
}),
}, {
link: markViewFactory({
component: Link,
as: 'span',
contentAs: 'span',
}),
}, [
new Plugin({
view: pluginViewFactory({
Expand Down Expand Up @@ -69,7 +77,7 @@ export const Editor: FC = () => {
return () => {
viewRef.current?.destroy()
}
}, [nodeViewFactory, pluginViewFactory, widgetViewFactory])
}, [nodeViewFactory, markViewFactory, pluginViewFactory, widgetViewFactory])

return <div className="editor" ref={editorRef} />
}
53 changes: 53 additions & 0 deletions e2e/src/react/components/Link.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useMarkViewContext } from '@prosemirror-adapter/react'
import { useEffect, useState } from 'react'

const linkCounter: Record<string, number> = {}
const linkListeners: Record<string, Set<VoidFunction>> = {}

function increaseLinkCount(link: string) {
linkCounter[link] ||= 0
linkCounter[link] += 1
linkListeners[link]?.forEach(fn => fn())
}

function addLinkListener(link: string, fn: VoidFunction): VoidFunction {
linkListeners[link] ||= new Set()
linkListeners[link].add(fn)
return () => linkListeners[link]?.delete(fn)
}

export function Link() {
const { contentRef, mark } = useMarkViewContext()
const [count, setCount] = useState(0)

const href = mark.attrs.href as string

useEffect(() => {
const dispose = addLinkListener(href, () => {
setCount(linkCounter[href] || 0)
})
return dispose
}, [href])

// Simulate link count increasement
useEffect(() => {
const id = setInterval(() => {
increaseLinkCount(href)
}, Math.round(Math.random() * 2000 + 1000))
return () => clearInterval(id)
}, [href])

return (
<a
href={href}
>
<span
ref={contentRef}
>
</span>
<span>
{count}
</span>
</a>
)
}
8 changes: 8 additions & 0 deletions e2e/src/react/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ <h3>Hello ProseMirror</h3>
text</strong>, <a href="http://marijnhaverbeke.nl/blog">links</a>, <code>code
font</code>, and <img src="https://prosemirror.net/img/smiley.png"> images.</p>

<p>There is a number after each link to indicate the number of visits. Click links to increase the number.
<ul>
<li><a href="https://example.com/foo">https://example.com/foo</a></li>
<li><a href="https://example.com/foo">https://example.com/foo</a></li>
<li><a href="https://example.com/bar">https://example.com/bar</a></li>
</ul>
</p>

<p>Block-level structure can be manipulated with key bindings (try
ctrl-shift-2 to create a level 2 heading, or enter in an empty
textblock to exit the parent block), or through the menu.</p>
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './markView'
export * from './nodeView'
export * from './pluginView'
export * from './widgetView'
112 changes: 112 additions & 0 deletions packages/core/src/markView/CoreMarkView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Mark } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'

import type {
CoreMarkViewSpec,
CoreMarkViewUserOptions,
MarkViewDOMSpec,
} from './CoreMarkViewOptions'

// TODO: Remove this definition and import MarkView from prosemirror-view once
// the PR below is released:
// https://github.com/ProseMirror/prosemirror-view/pull/174
interface MarkView {
dom: Node
contentDOM?: HTMLElement | null
destroy?: () => void
ignoreMutation?: (m: MutationRecord) => boolean
}

export class CoreMarkView<ComponentType> implements MarkView {
dom: HTMLElement
contentDOM: HTMLElement | undefined
mark: Mark
view: EditorView
inline: boolean
options: CoreMarkViewUserOptions<ComponentType>

#createElement(as?: MarkViewDOMSpec) {
const { inline, mark } = this
return as == null
? document.createElement(inline ? 'span' : 'div')
: as instanceof HTMLElement
? as
: as instanceof Function
? as(mark)
: document.createElement(as)
}

createDOM(as?: MarkViewDOMSpec) {
return this.#createElement(as)
}

createContentDOM(as?: MarkViewDOMSpec) {
return this.#createElement(as)
}

constructor({
mark,
view,
inline,
options,
}: CoreMarkViewSpec<ComponentType>) {
this.mark = mark
this.view = view
this.inline = inline
this.options = options

this.dom = this.createDOM(options.as)
this.contentDOM = options.contentAs
? this.createContentDOM(options.contentAs)
: undefined
this.dom.setAttribute('data-mark-view-root', 'true')
if (this.contentDOM) {
this.contentDOM.setAttribute('data-mark-view-content', 'true')
this.contentDOM.style.whiteSpace = 'inherit'
}
}

get component() {
return this.options.component
}

shouldIgnoreMutation: (mutation: MutationRecord) => boolean = (mutation) => {
if (!this.dom || !this.contentDOM)
return true

// @ts-expect-error: TODO: I will fix this on the PM side.
if (mutation.type === 'selection')
return false

if (this.contentDOM === mutation.target && mutation.type === 'attributes')
return true

if (this.contentDOM.contains(mutation.target))
return false

return true
}

ignoreMutation: (mutation: MutationRecord) => boolean = (mutation) => {
if (!this.dom || !this.contentDOM)
return true

let result

const userIgnoreMutation = this.options.ignoreMutation

if (userIgnoreMutation)
result = userIgnoreMutation(mutation)

if (typeof result !== 'boolean')
result = this.shouldIgnoreMutation(mutation)

return result
}

destroy: () => void = () => {
this.options.destroy?.()
this.dom.remove()
this.contentDOM?.remove()
}
}
25 changes: 25 additions & 0 deletions packages/core/src/markView/CoreMarkViewOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Mark } from 'prosemirror-model'
import type { EditorView } from 'prosemirror-view'

export type MarkViewDOMSpec = string | HTMLElement | ((mark: Mark) => HTMLElement)

export interface CoreMarkViewUserOptions<Component> {
// DOM
as?: MarkViewDOMSpec
contentAs?: MarkViewDOMSpec

// Component
component: Component

// Overrides
ignoreMutation?: (mutation: MutationRecord) => boolean | void
destroy?: () => void
}

export interface CoreMarkViewSpec<Component> {
mark: Mark
view: EditorView
inline: boolean

options: CoreMarkViewUserOptions<Component>
}
2 changes: 2 additions & 0 deletions packages/core/src/markView/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './CoreMarkView'
export * from './CoreMarkViewOptions'
11 changes: 6 additions & 5 deletions packages/core/src/nodeView/CoreNodeView.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Attrs, Node } from 'prosemirror-model'
import type { Decoration, DecorationSource, EditorView, NodeView } from 'prosemirror-view'

import type { CoreNodeViewSpec, CoreNodeViewUserOptions } from './CoreNodeViewOptions'
import type { CoreNodeViewSpec, CoreNodeViewUserOptions, NodeViewDOMSpec } from './CoreNodeViewOptions'

export class CoreNodeView<ComponentType> implements NodeView {
dom: HTMLElement
Expand All @@ -16,7 +16,7 @@ export class CoreNodeView<ComponentType> implements NodeView {
setSelection?: (anchor: number, head: number, root: Document | ShadowRoot) => void
stopEvent?: (event: Event) => boolean

#createElement(as?: string | HTMLElement | ((node: Node) => HTMLElement)) {
#createElement(as?: NodeViewDOMSpec) {
const { node } = this
return as == null
? document.createElement(node.isInline ? 'span' : 'div')
Expand All @@ -27,11 +27,11 @@ export class CoreNodeView<ComponentType> implements NodeView {
: document.createElement(as)
}

createDOM(as?: string | HTMLElement | ((node: Node) => HTMLElement)) {
createDOM(as?: NodeViewDOMSpec) {
return this.#createElement(as)
}

createContentDOM(as?: string | HTMLElement | ((node: Node) => HTMLElement)) {
createContentDOM(as?: NodeViewDOMSpec) {
return this.#createElement(as)
}

Expand Down Expand Up @@ -109,7 +109,8 @@ export class CoreNodeView<ComponentType> implements NodeView {
if (this.node.isLeaf || this.node.isAtom)
return true

if ((mutation.type as unknown) === 'selection')
// @ts-expect-error: TODO: I will fix this on the PM side.
if (mutation.type === 'selection')
return false

if (this.contentDOM === mutation.target && mutation.type === 'attributes')
Expand Down
18 changes: 12 additions & 6 deletions packages/react/src/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,39 @@ import React, { useMemo } from 'react'
import { createNodeViewContext } from './nodeView'
import { createPluginViewContext } from './pluginView/pluginViewContext'

import { createMarkViewContext } from './markView'
import { useReactMarkViewCreator } from './markView/useReactMarkViewCreator'
import { useReactNodeViewCreator } from './nodeView/useReactNodeViewCreator'
import { useReactPluginViewCreator } from './pluginView/useReactPluginViewCreator'
import { useReactRenderer } from './ReactRenderer'
import { createWidgetViewContext } from './widgetView'
import { useReactWidgetViewCreator } from './widgetView/useReactWidgetViewCreator'

export type CreateReactNodeView = ReturnType<typeof useReactNodeViewCreator>
export type CreateReactMarkView = ReturnType<typeof useReactMarkViewCreator>
export type CreateReactPluginView = ReturnType<typeof useReactPluginViewCreator>
export type CreateReactWidgetView = ReturnType<typeof useReactWidgetViewCreator>

export const ProsemirrorAdapterProvider: FC<{ children: ReactNode }> = ({ children }) => {
const { renderReactRenderer, removeReactRenderer, portals } = useReactRenderer()

const createReactNodeView: CreateReactNodeView = useReactNodeViewCreator(renderReactRenderer, removeReactRenderer)
const createReactMarkView: CreateReactMarkView = useReactMarkViewCreator(renderReactRenderer, removeReactRenderer)
const createReactPluginView: CreateReactPluginView = useReactPluginViewCreator(renderReactRenderer, removeReactRenderer)
const createReactWidgetView: CreateReactWidgetView = useReactWidgetViewCreator(renderReactRenderer, removeReactRenderer)

const memoizedPortals = useMemo(() => Object.values(portals), [portals])

return (
<createNodeViewContext.Provider value={createReactNodeView}>
<createPluginViewContext.Provider value={createReactPluginView}>
<createWidgetViewContext.Provider value={createReactWidgetView}>
{children}
{memoizedPortals}
</createWidgetViewContext.Provider>
</createPluginViewContext.Provider>
<createMarkViewContext.Provider value={createReactMarkView}>
<createPluginViewContext.Provider value={createReactPluginView}>
<createWidgetViewContext.Provider value={createReactWidgetView}>
{children}
{memoizedPortals}
</createWidgetViewContext.Provider>
</createPluginViewContext.Provider>
</createMarkViewContext.Provider>
</createNodeViewContext.Provider>
)
}
Loading
Loading