diff --git a/README.md b/README.md index d8d9f3b..b17659f 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ * Shared Editing using the [ProseMirror](http://prosemirror.net/) editor with versioning support - [Live Demo](https://demos.yjs.dev/prosemirror-versions/prosemirror-versions.html) +* Shared Editing using the [Dat Protocol](https://dat.foundation/) - [Live + Demo](https://demos.yjs.dev/prosemirror-dat/prosemirror-dat.html) * Shared Editing using the [Quill](https://quilljs.com/) editor - [Live Demo](https://demos.yjs.dev/quill/quill.html) * Shared Editing using the diff --git a/index.html b/index.html index 2343c9b..1bdde6f 100644 --- a/index.html +++ b/index.html @@ -9,6 +9,9 @@
This is a demo of the Yjs ⇔ ProseMirror binding: y-prosemirror.
+The content of this editor is shared with every client that visits this domain.
+ + diff --git a/prosemirror-dat/prosemirror.css b/prosemirror-dat/prosemirror.css new file mode 100644 index 0000000..4e5895f --- /dev/null +++ b/prosemirror-dat/prosemirror.css @@ -0,0 +1,330 @@ +.ProseMirror { + position: relative; +} + +.ProseMirror { + word-wrap: break-word; + white-space: pre-wrap; + -webkit-font-variant-ligatures: none; + font-variant-ligatures: none; +} + +.ProseMirror pre { + white-space: pre-wrap; +} + +.ProseMirror li { + position: relative; +} + +.ProseMirror-hideselection *::selection { background: transparent; } +.ProseMirror-hideselection *::-moz-selection { background: transparent; } +.ProseMirror-hideselection { caret-color: transparent; } + +.ProseMirror-selectednode { + outline: 2px solid #8cf; +} + +/* Make sure li selections wrap around markers */ + +li.ProseMirror-selectednode { + outline: none; +} + +li.ProseMirror-selectednode:after { + content: ""; + position: absolute; + left: -32px; + right: -2px; top: -2px; bottom: -2px; + border: 2px solid #8cf; + pointer-events: none; +} +.ProseMirror-textblock-dropdown { + min-width: 3em; +} + +.ProseMirror-menu { + margin: 0 -4px; + line-height: 1; +} + +.ProseMirror-tooltip .ProseMirror-menu { + width: -webkit-fit-content; + width: fit-content; + white-space: pre; +} + +.ProseMirror-menuitem { + margin-right: 3px; + display: inline-block; +} + +.ProseMirror-menuseparator { + border-right: 1px solid #ddd; + margin-right: 3px; +} + +.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu { + font-size: 90%; + white-space: nowrap; +} + +.ProseMirror-menu-dropdown { + vertical-align: 1px; + cursor: pointer; + position: relative; + padding-right: 15px; +} + +.ProseMirror-menu-dropdown-wrap { + padding: 1px 0 1px 4px; + display: inline-block; + position: relative; +} + +.ProseMirror-menu-dropdown:after { + content: ""; + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 2px); +} + +.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu { + position: absolute; + background: white; + color: #666; + border: 1px solid #aaa; + padding: 2px; +} + +.ProseMirror-menu-dropdown-menu { + z-index: 15; + min-width: 6em; +} + +.ProseMirror-menu-dropdown-item { + cursor: pointer; + padding: 2px 8px 2px 4px; +} + +.ProseMirror-menu-dropdown-item:hover { + background: #f2f2f2; +} + +.ProseMirror-menu-submenu-wrap { + position: relative; + margin-right: -4px; +} + +.ProseMirror-menu-submenu-label:after { + content: ""; + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid currentColor; + opacity: .6; + position: absolute; + right: 4px; + top: calc(50% - 4px); +} + +.ProseMirror-menu-submenu { + display: none; + min-width: 4em; + left: 100%; + top: -3px; +} + +.ProseMirror-menu-active { + background: #eee; + border-radius: 4px; +} + +.ProseMirror-menu-active { + background: #eee; + border-radius: 4px; +} + +.ProseMirror-menu-disabled { + opacity: .3; +} + +.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu { + display: block; +} + +.ProseMirror-menubar { + border-top-left-radius: inherit; + border-top-right-radius: inherit; + position: relative; + min-height: 1em; + color: #666; + padding: 1px 6px; + top: 0; left: 0; right: 0; + border-bottom: 1px solid silver; + background: white; + z-index: 10; + -moz-box-sizing: border-box; + box-sizing: border-box; + overflow: visible; +} + +.ProseMirror-icon { + display: inline-block; + line-height: .8; + vertical-align: -2px; /* Compensate for padding */ + padding: 2px 8px; + cursor: pointer; +} + +.ProseMirror-menu-disabled.ProseMirror-icon { + cursor: default; +} + +.ProseMirror-icon svg { + fill: currentColor; + height: 1em; +} + +.ProseMirror-icon span { + vertical-align: text-top; +} +.ProseMirror-gapcursor { + display: none; + pointer-events: none; + position: absolute; +} + +.ProseMirror-gapcursor:after { + content: ""; + display: block; + position: absolute; + top: -2px; + width: 20px; + border-top: 1px solid black; + animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite; +} + +@keyframes ProseMirror-cursor-blink { + to { + visibility: hidden; + } +} + +.ProseMirror-focused .ProseMirror-gapcursor { + display: block; +} +/* Add space around the hr to make clicking it easier */ + +.ProseMirror-example-setup-style hr { + padding: 2px 10px; + border: none; + margin: 1em 0; +} + +.ProseMirror-example-setup-style hr:after { + content: ""; + display: block; + height: 1px; + background-color: silver; + line-height: 2px; +} + +.ProseMirror ul, .ProseMirror ol { + padding-left: 30px; +} + +.ProseMirror blockquote { + padding-left: 1em; + border-left: 3px solid #eee; + margin-left: 0; margin-right: 0; +} + +.ProseMirror-example-setup-style img { + cursor: default; +} + +.ProseMirror-prompt { + background: white; + padding: 5px 10px 5px 15px; + border: 1px solid silver; + position: fixed; + border-radius: 3px; + z-index: 11; + box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2); +} + +.ProseMirror-prompt h5 { + margin: 0; + font-weight: normal; + font-size: 100%; + color: #444; +} + +.ProseMirror-prompt input[type="text"], +.ProseMirror-prompt textarea { + background: #eee; + border: none; + outline: none; +} + +.ProseMirror-prompt input[type="text"] { + padding: 0 4px; +} + +.ProseMirror-prompt-close { + position: absolute; + left: 2px; top: 1px; + color: #666; + border: none; background: transparent; padding: 0; +} + +.ProseMirror-prompt-close:after { + content: "✕"; + font-size: 12px; +} + +.ProseMirror-invalid { + background: #ffc; + border: 1px solid #cc7; + border-radius: 4px; + padding: 5px 10px; + position: absolute; + min-width: 10em; +} + +.ProseMirror-prompt-buttons { + margin-top: 5px; + display: none; +} +#editor, .editor { + background: white; + color: black; + background-clip: padding-box; + border-radius: 4px; + border: 2px solid rgba(0, 0, 0, 0.2); + padding: 5px 0; + margin-bottom: 23px; +} + +.ProseMirror p:first-child, +.ProseMirror h1:first-child, +.ProseMirror h2:first-child, +.ProseMirror h3:first-child, +.ProseMirror h4:first-child, +.ProseMirror h5:first-child, +.ProseMirror h6:first-child { + margin-top: 10px; +} + +.ProseMirror { + padding: 4px 8px 4px 14px; + line-height: 1.2; + outline: none; +} + +.ProseMirror p { margin-bottom: 1em } + diff --git a/prosemirror-dat/prosemirror.js b/prosemirror-dat/prosemirror.js new file mode 100644 index 0000000..82012ef --- /dev/null +++ b/prosemirror-dat/prosemirror.js @@ -0,0 +1,62 @@ +/* eslint-env browser */ + +import * as Y from 'yjs' +import { DatProvider } from 'y-dat' +import { ySyncPlugin, yCursorPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror' +import { EditorState } from 'prosemirror-state' +import { EditorView } from 'prosemirror-view' +import { schema } from './schema.js' +import { exampleSetup } from 'prosemirror-example-setup' +import { keymap } from 'prosemirror-keymap' + +window.addEventListener('load', () => { + const ydoc = new Y.Doc() + + // Create new Archive if no key is specified. + // • In the browser, specify the parameter in the url as the location.hash: "localhost:8080#7b0d584fcdaf1de2e8c473393a31f52327793931e03b330f7393025146dc02fb" + const givenDatKey = location.hash.length > 1 ? location.hash.slice(1) : null + + // @ts-ignore + const provider = new DatProvider(givenDatKey, ydoc) + const skey = provider.feed.key.toString('hex') + + console.log(`Collaborating on ${skey} ${givenDatKey === null ? '(generated)' : '(from parameter)'}`) + location.hash = '#' + skey + + const type = ydoc.getXmlFragment('prosemirror') + + const editor = document.createElement('div') + editor.setAttribute('id', 'editor') + const editorContainer = document.createElement('div') + editorContainer.insertBefore(editor, null) + const prosemirrorView = new EditorView(editor, { + state: EditorState.create({ + schema, + plugins: [ + ySyncPlugin(type), + yCursorPlugin(provider.awareness), + yUndoPlugin(), + keymap({ + 'Mod-z': undo, + 'Mod-y': redo, + 'Mod-Shift-z': redo + }) + ].concat(exampleSetup({ schema })) + }) + }) + document.body.insertBefore(editorContainer, null) + + const connectBtn = /** @type {HTMLElement} */ (document.getElementById('y-connect-btn')) + connectBtn.addEventListener('click', () => { + if (provider.shouldConnect) { + provider.disconnect() + connectBtn.textContent = 'Connect' + } else { + provider.connect() + connectBtn.textContent = 'Disconnect' + } + }) + + // @ts-ignore + window.example = { provider, ydoc, type, prosemirrorView } +}) diff --git a/prosemirror-dat/schema.js b/prosemirror-dat/schema.js new file mode 100644 index 0000000..12f272d --- /dev/null +++ b/prosemirror-dat/schema.js @@ -0,0 +1,201 @@ +import { Schema } from 'prosemirror-model' + +const brDOM = ['br'] + +const calcYchangeDomAttrs = (attrs, domAttrs = {}) => { + domAttrs = Object.assign({}, domAttrs) + if (attrs.ychange !== null) { + domAttrs.ychange_user = attrs.ychange.user + domAttrs.ychange_state = attrs.ychange.state + } + return domAttrs +} + +// :: Object +// [Specs](#model.NodeSpec) for the nodes defined in this schema. +export const nodes = { + // :: NodeSpec The top level document node. + doc: { + content: 'block+' + }, + + // :: NodeSpec A plain paragraph textblock. Represented in the DOM + // as a `` element. + paragraph: { + attrs: { ychange: { default: null } }, + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM (node) { return ['p', calcYchangeDomAttrs(node.attrs), 0] } + }, + + // :: NodeSpec A blockquote (`
`) wrapping one or more blocks. + blockquote: { + attrs: { ychange: { default: null } }, + content: 'block+', + group: 'block', + defining: true, + parseDOM: [{ tag: 'blockquote' }], + toDOM (node) { return ['blockquote', calcYchangeDomAttrs(node.attrs), 0] } + }, + + // :: NodeSpec A horizontal rule (`
`). + horizontal_rule: { + attrs: { ychange: { default: null } }, + group: 'block', + parseDOM: [{ tag: 'hr' }], + toDOM (node) { + return ['hr', calcYchangeDomAttrs(node.attrs)] + } + }, + + // :: NodeSpec A heading textblock, with a `level` attribute that + // should hold the number 1 to 6. Parsed and serialized as `` to + // `
` elements. + heading: { + attrs: { + level: { default: 1 }, + ychange: { default: null } + }, + content: 'inline*', + group: 'block', + defining: true, + parseDOM: [{ tag: 'h1', attrs: { level: 1 } }, + { tag: 'h2', attrs: { level: 2 } }, + { tag: 'h3', attrs: { level: 3 } }, + { tag: 'h4', attrs: { level: 4 } }, + { tag: 'h5', attrs: { level: 5 } }, + { tag: 'h6', attrs: { level: 6 } }], + toDOM (node) { return ['h' + node.attrs.level, calcYchangeDomAttrs(node.attrs), 0] } + }, + + // :: NodeSpec A code listing. Disallows marks or non-text inline + // nodes by default. Represented as a `
` element with a + // `` element inside of it. + code_block: { + attrs: { ychange: { default: null } }, + content: 'text*', + marks: '', + group: 'block', + code: true, + defining: true, + parseDOM: [{ tag: 'pre', preserveWhitespace: 'full' }], + toDOM (node) { return ['pre', calcYchangeDomAttrs(node.attrs), ['code', 0]] } + }, + + // :: NodeSpec The text node. + text: { + group: 'inline' + }, + + // :: NodeSpec An inline image (``) node. Supports `src`, + // `alt`, and `href` attributes. The latter two default to the empty + // string. + image: { + inline: true, + attrs: { + ychange: { default: null }, + src: {}, + alt: { default: null }, + title: { default: null } + }, + group: 'inline', + draggable: true, + parseDOM: [{ + tag: 'img[src]', + getAttrs (dom) { + return { + src: dom.getAttribute('src'), + title: dom.getAttribute('title'), + alt: dom.getAttribute('alt') + } + } + }], + toDOM (node) { + const domAttrs = { + src: node.attrs.src, + title: node.attrs.title, + alt: node.attrs.alt + } + return ['img', calcYchangeDomAttrs(node.attrs, domAttrs)] + } + }, + + // :: NodeSpec A hard line break, represented in the DOM as `
`. + hard_break: { + inline: true, + group: 'inline', + selectable: false, + parseDOM: [{ tag: 'br' }], + toDOM () { return brDOM } + } +} + +const emDOM = ['em', 0]; const strongDOM = ['strong', 0]; const codeDOM = ['code', 0] + +// :: 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 `` + // 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) { return ['a', node.attrs, 0] } + }, + + // :: MarkSpec An emphasis mark. Rendered as an `` element. + // Has parse rules that also match `` and `font-style: italic`. + em: { + parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }], + toDOM () { return emDOM } + }, + + // :: MarkSpec A strong mark. Rendered as ``, parse rules + // also match `` and `font-weight: bold`. + strong: { + parseDOM: [{ tag: 'strong' }, + // This works around a Google Docs misbehavior where + // pasted content will be inexplicably wrapped in `` + // 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 `` element. + code: { + parseDOM: [{ tag: 'code' }], + toDOM () { return codeDOM } + }, + ychange: { + attrs: { + user: { default: null }, + state: { default: null } + }, + inclusive: false, + parseDOM: [{ tag: 'ychange' }], + toDOM (node) { + return ['ychange', { ychange_user: node.attrs.user, ychange_state: node.attrs.state }, 0] + } + } +} + +// :: Schema +// This schema rougly 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 }) diff --git a/prosemirror-dat/webpack.config.js b/prosemirror-dat/webpack.config.js new file mode 100644 index 0000000..e74143b --- /dev/null +++ b/prosemirror-dat/webpack.config.js @@ -0,0 +1,25 @@ +const path = require('path') + +module.exports = { + mode: 'development', + devtool: 'source-map', + entry: { + prosemirror: './prosemirror.js' + }, + output: { + globalObject: 'self', + path: path.resolve(__dirname, './dist/'), + filename: '[name].bundle.js', + publicPath: '/prosemirror/dist/' + }, + devServer: { + contentBase: path.join(__dirname), + compress: true, + publicPath: '/dist/' + }, + resolve: { + alias: { + fs: 'graceful-fs' + } + } +} diff --git a/prosemirror/prosemirror.html b/prosemirror/prosemirror.html index 92faeef..0454fc8 100644 --- a/prosemirror/prosemirror.html +++ b/prosemirror/prosemirror.html @@ -18,6 +18,7 @@ font-weight: bold; } .ProseMirror img { max-width: 100px } + /* this is a rough fix for the first cursor position when the first paragraph is empty */ .ProseMirror > .ProseMirror-yjs-cursor:first-child { margin-top: 16px; @@ -25,19 +26,22 @@ .ProseMirror p:first-child, .ProseMirror h1:first-child, .ProseMirror h2:first-child, .ProseMirror h3:first-child, .ProseMirror h4:first-child, .ProseMirror h5:first-child, .ProseMirror h6:first-child { margin-top: 16px } + /* This gives the remote user caret. The colors are automatically overwritten*/ .ProseMirror-yjs-cursor { - position: absolute; - border-left: black; - border-left-style: solid; - border-left-width: 2px; + position: relative; + margin-left: -1px; + margin-right: -1px; + border-left: 1px solid black; + border-right: 1px solid black; border-color: orange; - height: 1em; word-break: normal; pointer-events: none; } + /* This renders the username above the caret */ .ProseMirror-yjs-cursor > div { - position: relative; + position: absolute; top: -1.05em; + left: -1px; font-size: 13px; background-color: rgb(250, 129, 0); font-family: serif; @@ -48,6 +52,7 @@ color: white; padding-left: 2px; padding-right: 2px; + white-space: nowrap; } #y-functions { position: absolute; diff --git a/quill/quill.js b/quill/quill.js index a068f7b..8e97a7f 100644 --- a/quill/quill.js +++ b/quill/quill.js @@ -54,5 +54,6 @@ window.addEventListener('load', () => { } }) + // @ts-ignore window.example = { provider, ydoc, type, binding } })