Compare commits

...

33 commits

Author SHA1 Message Date
Dan Brown
d34f837e19
Started work on details/summary blocks 2022-01-21 17:07:27 +00:00
Dan Brown
264966de02
Crawled forward slightly on table resizing 2022-01-21 12:16:05 +00:00
Dan Brown
8b4f112462
Improved iframe embed interaction within editor 2022-01-20 13:55:44 +00:00
Dan Brown
20f37292a1
Added support for iframe node blocks 2022-01-20 13:38:16 +00:00
Dan Brown
b1f5495a7f
Shared link mark update logic with color controls 2022-01-19 23:54:59 +00:00
Dan Brown
bb12541179
Improved anchor updating/remove action
Now will update the link mark if you have a no-range selection on the
link.
2022-01-19 23:22:48 +00:00
Dan Brown
e3ead1c115
Added radio options for anchor target option 2022-01-19 22:14:09 +00:00
Dan Brown
9b4ea368dc
Started on table editing/resizing 2022-01-19 16:46:45 +00:00
Dan Brown
4b08eef12c
Added table creation and insertion 2022-01-19 15:22:10 +00:00
Dan Brown
b2283106fc
Added source code view/set button 2022-01-19 11:31:02 +00:00
Dan Brown
7125530e55
Added image resizing via drag handles 2022-01-17 17:43:16 +00:00
Dan Brown
7622106665
Added jsdoc types for prosemirror
Also added link markdown handling when target is set.
2022-01-16 15:21:57 +00:00
Dan Brown
89194a3f85
Got link insert/editor working 2022-01-16 14:37:58 +00:00
Dan Brown
7703face52
Started menu dialog support 2022-01-14 20:56:05 +00:00
Dan Brown
c013d7e549
Added inline code and clear formatting 2022-01-14 18:27:37 +00:00
Dan Brown
07c8876e22
Imported marks from example schema for customization 2022-01-14 14:55:07 +00:00
Dan Brown
0dc64d22ef
Added horizonal rule insert 2022-01-14 14:33:37 +00:00
Dan Brown
013943dcc5
Added list buttons 2022-01-14 13:14:25 +00:00
Dan Brown
dc1c9807ef
Reorganised & aligned editor icons 2022-01-12 16:10:16 +00:00
Dan Brown
56d7864bdf
Added bg-color mark, added color grid selectors 2022-01-12 15:33:59 +00:00
Dan Brown
1018b5627e
Added text color mark 2022-01-12 11:02:28 +00:00
Dan Brown
717557df89
Rolled out text alignment to other block types
Completed off alignment types and markdown handling in the process.
2022-01-12 10:18:06 +00:00
Dan Brown
6744ab2ff9
Got alignment buttons barely working for paragraphs 2022-01-11 18:58:24 +00:00
Dan Brown
4e5153d372
Copied in default node types for control and future editing 2022-01-11 17:13:40 +00:00
Dan Brown
34db138a64
Split marks and nodes into their own files 2022-01-11 16:26:12 +00:00
Dan Brown
c3595b1807
Added strike, sup and sub marks 2022-01-11 16:00:57 +00:00
Dan Brown
a8f48185b5
Got underline working in editor
Major step, since this is the first inline HTML element which needed
advanced parsing out on the markdown side, since not commonmark
supported.
2022-01-10 13:38:32 +00:00
Dan Brown
9d7174557e
Added in a custom menubar
This is a copy of the ProseMirror/prosemirror-menu repo files
which suggest working from a fork of this.

These changes include the ability to select callouts
from the menubar.
2022-01-09 16:37:16 +00:00
Dan Brown
47c3d4fc0f
Fixed issue with new nodes being callouts 2022-01-07 21:56:04 +00:00
Dan Brown
81dfe9c345
Got callouts about working, simplified markdown setup 2022-01-07 21:22:07 +00:00
Dan Brown
0fb8ba00a5
Attempted adding tricky custom block
Attempted adding callouts, which have the challenge of being shown via
HTML within markdown content. Got stuck on parsing back to the state
from markdown.
2022-01-07 16:37:36 +00:00
Dan Brown
aa9fe9ca82
Added notes file 2022-01-07 13:36:53 +00:00
Dan Brown
27f9e8e4bd
Started playing with prosemirror
- Got base setup together with WYSIWYG/Markdown switching, where HTML is
  the base content format.
- Added some testing routes/views for initial development.
- Added some dev npm tasks to support editor-specific actions.
2022-01-07 13:36:52 +00:00
41 changed files with 4668 additions and 0 deletions

49
TODO Normal file
View file

@ -0,0 +1,49 @@
### Next
- Table cell height resize & cell width resize via width style
- Column resize source: https://github.com/ProseMirror/prosemirror-tables/blob/master/src/columnresizing.js
- Have updated column resizing to set cell widths
- Now need to handle table overall size on change, then heights.
- Details/Summary
- Need view to control summary editability, make readonly but editable via popover.
- Need some default styles to visualise details boundary.
- Markdown parser needs to be updated to handle separate open/close tags for blocks.
### In-Progress
- Tables
- Details/Summary
### Features
- Images
- Drawings
- LTR/RTL control
- Fullscreen
- Paste Image Uploading
- Drag + Drop Image Uploading
- Checkbox/TODO list items
- Code blocks
- Indents
- Attachment integration (Drag & drop)
- Template system integration.
### Improvements
- List type changing.
- Color picker options should have "clear" option.
- Color picker buttons should be split, with button to re-apply last selected color.
- Color picker options should change color if different instead of remove.
- Clear formatting, If no selection range, clear the formatting of parent block.
- If no marks, clear the block type if text type?
- Remove links button? (Action already in place if link href is empty).
- Links - Validate URL.
- Links - Integrate entity picker.
- iFrame - Parse iframe HTML & auto-convert youtube/vimeo urls to embeds.
### Notes
- Use NodeViews for embedded content (Code, Drawings) where control is needed.
- Probably still easiest to have seperate (codemirror) MD editor. Can alter display output via NodeViews to make MD like
but its tricky since editing the markdown content would change the block definition/type while editing.

View file

@ -2,6 +2,7 @@
namespace BookStack\Uploads;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\User;
use BookStack\Exceptions\HttpFetchException;
use Exception;
@ -16,6 +17,7 @@ class UserAvatars
{
$this->imageService = $imageService;
$this->http = $http;
$ldapService = app()->make(LdapService::class);
}
/**

432
package-lock.json generated
View file

@ -7,9 +7,18 @@
"dependencies": {
"clipboard": "^2.0.8",
"codemirror": "^5.63.3",
"crelt": "^1.0.5",
"dropzone": "^5.9.3",
"markdown-it": "^12.2.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-commands": "^1.1.12",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.6.0",
"prosemirror-model": "^1.15.0",
"prosemirror-schema-list": "^1.1.6",
"prosemirror-state": "^1.3.4",
"prosemirror-tables": "^1.1.1",
"prosemirror-view": "^1.23.2",
"sortablejs": "^1.14.0"
},
"devDependencies": {
@ -219,6 +228,11 @@
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
},
"node_modules/crelt": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz",
"integrity": "sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA=="
},
"node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@ -1227,6 +1241,11 @@
"integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
"dev": true
},
"node_modules/orderedmap": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-1.1.1.tgz",
"integrity": "sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ=="
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -1345,6 +1364,193 @@
"node": ">=4"
}
},
"node_modules/prosemirror-commands": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.1.12.tgz",
"integrity": "sha512-+CrMs3w/ZVPSkR+REg8KL/clyFLv/1+SgY/OMN+CB22Z24j9TZDje72vL36lOZ/E4NeRXuiCcmENcW/vAcG67A==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz",
"integrity": "sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-example-setup": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.1.2.tgz",
"integrity": "sha512-MTpIMyqk08jFnzxeRMCinCEMtVSTUtxKgQBGxfCbVe9C6zIOqp9qZZJz5Ojaad1GETySyuj8+OIHHvQsIaaaGQ==",
"dependencies": {
"prosemirror-commands": "^1.0.0",
"prosemirror-dropcursor": "^1.0.0",
"prosemirror-gapcursor": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-inputrules": "^1.0.0",
"prosemirror-keymap": "^1.0.0",
"prosemirror-menu": "^1.0.0",
"prosemirror-schema-list": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.0.tgz",
"integrity": "sha512-yCLy5+0rVqLir/KcHFathQj4Rf8aRHi80FmEfKtM0JmyzvwdomslLzDZ/pX4oFhFKDgjl/WBBBFNqDyNifWg7g==",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.2.0.tgz",
"integrity": "sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz",
"integrity": "sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz",
"integrity": "sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.6.0.tgz",
"integrity": "sha512-y/gRpJIIrNArtkyMax7ypYafb+ZMjddbVHI+AwlcUfCLCCXK57cOmfBMKYVq9kdEKJYVdYHdoyWsVNn1nWLHUg==",
"dependencies": {
"markdown-it": "^10.0.0",
"prosemirror-model": "^1.0.0"
}
},
"node_modules/prosemirror-markdown/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/prosemirror-markdown/node_modules/entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
},
"node_modules/prosemirror-markdown/node_modules/linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/prosemirror-markdown/node_modules/markdown-it": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
"dependencies": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
"linkify-it": "^2.0.0",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"bin": {
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/prosemirror-menu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.1.4.tgz",
"integrity": "sha512-2ROsji/X9ciDnVSRvSTqFygI34GEdHfQSsK4zBKjPxSEroeiHHcdRMS1ofNIf2zM0Vpp5/YqfpxynElymQkqzg==",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.15.0.tgz",
"integrity": "sha512-hQJv7SnIhlAy9ga3lhPPgaufhvCbQB9tHwscJ9E1H1pPHmN8w5V/lURueoYv9Kc3/bpNWoyHa8r3g//m7N0ChQ==",
"dependencies": {
"orderedmap": "^1.1.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz",
"integrity": "sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-state": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.3.4.tgz",
"integrity": "sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz",
"integrity": "sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==",
"dependencies": {
"prosemirror-keymap": "^1.1.2",
"prosemirror-model": "^1.8.1",
"prosemirror-state": "^1.3.1",
"prosemirror-transform": "^1.2.1",
"prosemirror-view": "^1.13.3"
}
},
"node_modules/prosemirror-transform": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.3.3.tgz",
"integrity": "sha512-9NLVXy1Sfa2G6qPqhWMkEvwQQMTw7OyTqOZbJaGQWsCeH3hH5Cw+c5eNaLM1Uu75EyKLsEZhJ93XpHJBa6RX8A==",
"dependencies": {
"prosemirror-model": "^1.0.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.23.2.tgz",
"integrity": "sha512-iPgRw6tpcN+KH1yKmSnRmDKsJBVkWLFP6laHcz9rh/n0Ndz7YKKCDldtw6FhHBYoWmZeubbhV/rrQW0VCDG9iw==",
"dependencies": {
"prosemirror-model": "^1.14.3",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -1408,6 +1614,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rope-sequence": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.2.tgz",
"integrity": "sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg=="
},
"node_modules/sass": {
"version": "1.43.4",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz",
@ -1521,6 +1732,11 @@
"integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==",
"dev": true
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"node_modules/string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
@ -1658,6 +1874,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.4.tgz",
"integrity": "sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw=="
},
"node_modules/which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
@ -1926,6 +2147,11 @@
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
},
"crelt": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.5.tgz",
"integrity": "sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA=="
},
"cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@ -2630,6 +2856,11 @@
"integrity": "sha512-k41FwbcLnlgnFh69f4qdUfvDQ+5vaSDnVPFI/y5XuhKRq97EnVVneO9F1ESVCdiVu4fCS2L8usX3mU331hB7pg==",
"dev": true
},
"orderedmap": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-1.1.1.tgz",
"integrity": "sha512-3Ux8um0zXbVacKUkcytc0u3HgC0b0bBLT+I60r2J/En72cI0nZffqrA7Xtf2Hqs27j1g82llR5Mhbd0Z1XW4AQ=="
},
"p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
@ -2709,6 +2940,192 @@
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"dev": true
},
"prosemirror-commands": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.1.12.tgz",
"integrity": "sha512-+CrMs3w/ZVPSkR+REg8KL/clyFLv/1+SgY/OMN+CB22Z24j9TZDje72vL36lOZ/E4NeRXuiCcmENcW/vAcG67A==",
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-dropcursor": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.4.0.tgz",
"integrity": "sha512-6+YwTjmqDwlA/Dm+5wK67ezgqgjA/MhSDgaNxKUzH97SmeuWFXyLeDRxxOPZeSo7yTxcDGUCWTEjmQZsVBuMrQ==",
"requires": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"prosemirror-example-setup": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prosemirror-example-setup/-/prosemirror-example-setup-1.1.2.tgz",
"integrity": "sha512-MTpIMyqk08jFnzxeRMCinCEMtVSTUtxKgQBGxfCbVe9C6zIOqp9qZZJz5Ojaad1GETySyuj8+OIHHvQsIaaaGQ==",
"requires": {
"prosemirror-commands": "^1.0.0",
"prosemirror-dropcursor": "^1.0.0",
"prosemirror-gapcursor": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-inputrules": "^1.0.0",
"prosemirror-keymap": "^1.0.0",
"prosemirror-menu": "^1.0.0",
"prosemirror-schema-list": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"prosemirror-gapcursor": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.2.0.tgz",
"integrity": "sha512-yCLy5+0rVqLir/KcHFathQj4Rf8aRHi80FmEfKtM0JmyzvwdomslLzDZ/pX4oFhFKDgjl/WBBBFNqDyNifWg7g==",
"requires": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"prosemirror-history": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.2.0.tgz",
"integrity": "sha512-B9v9xtf4fYbKxQwIr+3wtTDNLDZcmMMmGiI3TAPShnUzvo+Rmv1GiUrsQChY1meetHl7rhML2cppF3FTs7f7UQ==",
"requires": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"rope-sequence": "^1.3.0"
}
},
"prosemirror-inputrules": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.1.3.tgz",
"integrity": "sha512-ZaHCLyBtvbyIHv0f5p6boQTIJjlD6o2NPZiEaZWT2DA+j591zS29QQEMT4lBqwcLW3qRSf7ZvoKNbf05YrsStw==",
"requires": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-keymap": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.1.5.tgz",
"integrity": "sha512-8SZgPH3K+GLsHL2wKuwBD9rxhsbnVBTwpHCO4VUO5GmqUQlxd/2GtBVWTsyLq4Dp3N9nGgPd3+lZFKUDuVp+Vw==",
"requires": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"prosemirror-markdown": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.6.0.tgz",
"integrity": "sha512-y/gRpJIIrNArtkyMax7ypYafb+ZMjddbVHI+AwlcUfCLCCXK57cOmfBMKYVq9kdEKJYVdYHdoyWsVNn1nWLHUg==",
"requires": {
"markdown-it": "^10.0.0",
"prosemirror-model": "^1.0.0"
},
"dependencies": {
"argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"requires": {
"sprintf-js": "~1.0.2"
}
},
"entities": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz",
"integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ=="
},
"linkify-it": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
"integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
"requires": {
"uc.micro": "^1.0.1"
}
},
"markdown-it": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz",
"integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==",
"requires": {
"argparse": "^1.0.7",
"entities": "~2.0.0",
"linkify-it": "^2.0.0",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
}
}
}
},
"prosemirror-menu": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.1.4.tgz",
"integrity": "sha512-2ROsji/X9ciDnVSRvSTqFygI34GEdHfQSsK4zBKjPxSEroeiHHcdRMS1ofNIf2zM0Vpp5/YqfpxynElymQkqzg==",
"requires": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"prosemirror-model": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.15.0.tgz",
"integrity": "sha512-hQJv7SnIhlAy9ga3lhPPgaufhvCbQB9tHwscJ9E1H1pPHmN8w5V/lURueoYv9Kc3/bpNWoyHa8r3g//m7N0ChQ==",
"requires": {
"orderedmap": "^1.1.0"
}
},
"prosemirror-schema-list": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.1.6.tgz",
"integrity": "sha512-aFGEdaCWmJzouZ8DwedmvSsL50JpRkqhQ6tcpThwJONVVmCgI36LJHtoQ4VGZbusMavaBhXXr33zyD2IVsTlkw==",
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-state": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.3.4.tgz",
"integrity": "sha512-Xkkrpd1y/TQ6HKzN3agsQIGRcLckUMA9u3j207L04mt8ToRgpGeyhbVv0HI7omDORIBHjR29b7AwlATFFf2GLA==",
"requires": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"prosemirror-tables": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.1.1.tgz",
"integrity": "sha512-LmCz4jrlqQZRsYRDzCRYf/pQ5CUcSOyqZlAj5kv67ZWBH1SVLP2U9WJEvQfimWgeRlIz0y0PQVqO1arRm1+woA==",
"requires": {
"prosemirror-keymap": "^1.1.2",
"prosemirror-model": "^1.8.1",
"prosemirror-state": "^1.3.1",
"prosemirror-transform": "^1.2.1",
"prosemirror-view": "^1.13.3"
}
},
"prosemirror-transform": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.3.3.tgz",
"integrity": "sha512-9NLVXy1Sfa2G6qPqhWMkEvwQQMTw7OyTqOZbJaGQWsCeH3hH5Cw+c5eNaLM1Uu75EyKLsEZhJ93XpHJBa6RX8A==",
"requires": {
"prosemirror-model": "^1.0.0"
}
},
"prosemirror-view": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.23.2.tgz",
"integrity": "sha512-iPgRw6tpcN+KH1yKmSnRmDKsJBVkWLFP6laHcz9rh/n0Ndz7YKKCDldtw6FhHBYoWmZeubbhV/rrQW0VCDG9iw==",
"requires": {
"prosemirror-model": "^1.14.3",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@ -2757,6 +3174,11 @@
"path-parse": "^1.0.6"
}
},
"rope-sequence": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.2.tgz",
"integrity": "sha512-ku6MFrwEVSVmXLvy3dYph3LAMNS0890K7fabn+0YIRQ2T96T9F4gkFf0vf0WW0JUraNWwGRtInEpH7yO4tbQZg=="
},
"sass": {
"version": "1.43.4",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.43.4.tgz",
@ -2852,6 +3274,11 @@
"integrity": "sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==",
"dev": true
},
"sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
},
"string-width": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
@ -2959,6 +3386,11 @@
"spdx-expression-parse": "^3.0.0"
}
},
"w3c-keyname": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.4.tgz",
"integrity": "sha512-tOhfEwEzFLJzf6d1ZPkYfGj+FWhIpBux9ppoP3rlclw3Z0BZv3N7b7030Z1kYth+6rDuAsXUFr+d0VE6Ed1ikw=="
},
"which": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",

View file

@ -7,6 +7,8 @@
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js:watch": "chokidar --initial \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
"build:js_editor:dev": "esbuild --bundle ./resources/js/editor.js --outfile=public/dist/editor.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js_editor:watch": "chokidar --initial \"./resources/js/editor.js\" \"./resources/js/editor/**/*.js\" -c \"npm run build:js_editor:dev\"",
"build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production",
"dev": "npm-run-all --parallel watch livereload",
@ -25,9 +27,18 @@
"dependencies": {
"clipboard": "^2.0.8",
"codemirror": "^5.63.3",
"crelt": "^1.0.5",
"dropzone": "^5.9.3",
"markdown-it": "^12.2.0",
"markdown-it-task-lists": "^2.1.1",
"prosemirror-commands": "^1.1.12",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-markdown": "^1.6.0",
"prosemirror-model": "^1.15.0",
"prosemirror-schema-list": "^1.1.6",
"prosemirror-state": "^1.3.4",
"prosemirror-tables": "^1.1.1",
"prosemirror-view": "^1.23.2",
"sortablejs": "^1.14.0"
}
}

18
resources/js/editor.js Normal file
View file

@ -0,0 +1,18 @@
import MarkdownView from "./editor/MarkdownView";
import ProseMirrorView from "./editor/ProseMirrorView";
// Next step: https://prosemirror.net/examples/menu/
const place = document.querySelector("#editor");
let view = new ProseMirrorView(place, document.getElementById('content').innerHTML);
const markdownToggle = document.getElementById('markdown-toggle');
markdownToggle.addEventListener('change', event => {
const View = markdownToggle.checked ? MarkdownView : ProseMirrorView;
if (view instanceof View) return
const content = view.content
console.log(content);
view.destroy()
view = new View(place, content)
view.focus()
});

View file

@ -0,0 +1,28 @@
import {htmlToDoc, docToHtml} from "./util";
import parser from "./markdown-parser";
import serializer from "./markdown-serializer";
class MarkdownView {
constructor(target, content) {
// Build DOM from content
const htmlDoc = htmlToDoc(content);
const markdown = serializer.serialize(htmlDoc);
this.textarea = target.appendChild(document.createElement("textarea"))
this.textarea.value = markdown;
this.textarea.style.width = '1000px';
this.textarea.style.height = '1000px';
}
get content() {
const markdown = this.textarea.value;
const doc = parser.parse(markdown);
return docToHtml(doc);
}
focus() { this.textarea.focus() }
destroy() { this.textarea.remove() }
}
export default MarkdownView;

View file

@ -0,0 +1,52 @@
import {EditorState} from "prosemirror-state";
import {EditorView} from "prosemirror-view";
import {exampleSetup} from "prosemirror-example-setup";
import {tableEditing} from "prosemirror-tables";
import {DOMParser} from "prosemirror-model";
import schema from "./schema";
import menu from "./menu";
import nodeViews from "./node-views";
import {stateToHtml} from "./util";
import {columnResizing} from "./plugins/table-resizing";
class ProseMirrorView {
constructor(target, content) {
// Build DOM from content
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.innerHTML = content;
this.view = new EditorView(target, {
state: EditorState.create({
doc: DOMParser.fromSchema(schema).parse(renderDoc.body),
plugins: [
...exampleSetup({schema, menuBar: false}),
menu,
columnResizing(),
tableEditing(),
]
}),
nodeViews,
});
// Fix for native handles (Such as table size handling) in some browsers
document.execCommand("enableObjectResizing", false, "false")
document.execCommand("enableInlineTableEditing", false, "false")
}
get content() {
return stateToHtml(this.view.state);
}
focus() {
this.view.focus()
}
destroy() {
this.view.destroy()
}
}
export default ProseMirrorView;

View file

@ -0,0 +1,102 @@
/**
* @param {String} attrName
* @param {String} attrValue
* @return {PmCommandHandler}
*/
export function setBlockAttr(attrName, attrValue) {
return function (state, dispatch) {
const ref = state.selection;
const from = ref.from;
const to = ref.to;
let applicable = false;
state.doc.nodesBetween(from, to, function (node, pos) {
if (applicable) {
return false
}
if (!node.isTextblock || node.attrs[attrName] === attrValue) {
return
}
applicable = node.attrs[attrName] !== undefined;
});
if (!applicable) {
return false
}
if (dispatch) {
const tr = state.tr;
tr.doc.nodesBetween(from, to, function (node, pos) {
const nodeAttrs = Object.assign({}, node.attrs);
if (node.attrs[attrName] !== undefined) {
nodeAttrs[attrName] = attrValue;
tr.setBlockType(pos, pos + 1, node.type, nodeAttrs)
}
});
dispatch(tr);
}
return true
}
}
/**
* @param {PmNodeType} blockType
* @return {PmCommandHandler}
*/
export function insertBlockBefore(blockType) {
return function (state, dispatch) {
const startPosition = state.selection.$from.before(1);
if (dispatch) {
dispatch(state.tr.insert(startPosition, blockType.create()));
}
return true
}
}
/**
* @param {Number} rows
* @param {Number} columns
* @param {Object} tableAttrs
* @return {PmCommandHandler}
*/
export function insertTable(rows, columns, tableAttrs) {
return function (state, dispatch) {
if (!dispatch) return true;
const tr = state.tr;
const nodes = state.schema.nodes;
const rowNodes = [];
for (let y = 0; y < rows; y++) {
const rowCells = [];
for (let x = 0; x < columns; x++) {
const cellText = nodes.paragraph.create(null);
rowCells.push(nodes.table_cell.create(null, cellText));
}
rowNodes.push(nodes.table_row.create(null, rowCells));
}
const table = nodes.table.create(tableAttrs, rowNodes);
tr.replaceSelectionWith(table);
dispatch(tr);
return true;
}
}
/**
* @return {PmCommandHandler}
*/
export function removeMarks() {
return function (state, dispatch) {
if (dispatch) {
dispatch(state.tr.removeMark(state.selection.from, state.selection.to, null));
}
return true;
}
}

View file

@ -0,0 +1,69 @@
import schema from "./schema";
import markdownit from "markdown-it";
import {MarkdownParser, defaultMarkdownParser} from "prosemirror-markdown";
import {htmlToDoc, KeyedMultiStack} from "./util";
const tokens = defaultMarkdownParser.tokens;
// These are really a placeholder on the object to allow the below
// parser.tokenHandlers.html_[block/inline] hacks to work as desired.
tokens.html_block = {block: "callout", noCloseToken: true};
tokens.html_inline = {mark: "underline"};
const tokenizer = markdownit("commonmark", {html: true});
const parser = new MarkdownParser(schema, tokenizer, tokens);
// When we come across HTML blocks we use the document schema to parse them
// into nodes then re-add those back into the parser state.
parser.tokenHandlers.html_block = function(state, tok, tokens, i) {
const contentDoc = htmlToDoc(tok.content || '');
for (const node of contentDoc.content.content) {
state.addNode(node.type, node.attrs, node.content);
}
};
// When we come across inline HTML we parse out the tag and keep track of
// that in a stack, along with the marks they parse out to.
// We open/close the marks within the state depending on the tag open/close type.
const tagStack = new KeyedMultiStack();
parser.tokenHandlers.html_inline = function(state, tok, tokens, i) {
const isClosing = tok.content.startsWith('</');
const isSelfClosing = tok.content.endsWith('/>');
const tagName = parseTagNameFromHtmlTokenContent(tok.content);
if (!isClosing) {
const completeTag = isSelfClosing ? tok.content : `${tok.content}a</${tagName}>`;
const marks = extractMarksFromHtml(completeTag);
tagStack.push(tagName, marks);
for (const mark of marks) {
state.openMark(mark);
}
}
if (isSelfClosing || isClosing) {
const marks = (tagStack.pop(tagName) || []).reverse();
for (const mark of marks) {
state.closeMark(mark);
}
}
}
/**
* @param {String} html
* @return {PmMark[]}
*/
function extractMarksFromHtml(html) {
const contentDoc = htmlToDoc('<p>' + (html || '') + '</p>');
const marks = contentDoc?.content?.content?.[0]?.content?.content?.[0]?.marks;
return marks || [];
}
/**
* @param {string} tokenContent
* @return {string}
*/
function parseTagNameFromHtmlTokenContent(tokenContent) {
return tokenContent.split(' ')[0].replace(/[<>\/]/g, '').toLowerCase();
}
export default parser;

View file

@ -0,0 +1,138 @@
import {MarkdownSerializer, defaultMarkdownSerializer, MarkdownSerializerState} from "prosemirror-markdown";
import {docToHtml} from "./util";
const nodes = defaultMarkdownSerializer.nodes;
const marks = defaultMarkdownSerializer.marks;
nodes.callout = function (state, node) {
writeNodeAsHtml(state, node);
};
nodes.table = function (state, node) {
writeNodeAsHtml(state, node);
};
nodes.iframe = function (state, node) {
writeNodeAsHtml(state, node);
};
nodes.details = function (state, node) {
wrapNodeWithHtml(state, node, '<details>', '</details>');
};
nodes.details_summary = function(state, node) {
writeNodeAsHtml(state, node);
};
function isPlainURL(link, parent, index, side) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) {
return false
}
const content = parent.child(index + (side < 0 ? -1 : 0));
if (!content.isText || content.text != link.attrs.href || content.marks[content.marks.length - 1] != link) {
return false
}
if (index == (side < 0 ? 1 : parent.childCount - 1)) {
return true
}
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks)
}
marks.link = {
open(state, mark, parent, index) {
const attrs = mark.attrs;
if (attrs.target) {
return `<a href="${attrs.target}" ${attrs.title ? `title="${attrs.title}"` : ''} target="${attrs.target}">`
}
return isPlainURL(mark, parent, index, 1) ? "<" : "["
},
close(state, mark, parent, index) {
if (mark.attrs.target) {
return `</a>`;
}
return isPlainURL(mark, parent, index, -1) ? ">"
: "](" + state.esc(mark.attrs.href) + (mark.attrs.title ? " " + state.quote(mark.attrs.title) : "") + ")"
}
};
marks.underline = {
open: '<span style="text-decoration: underline;">',
close: '</span>',
};
marks.strike = {
open: '<span style="text-decoration: line-through;">',
close: '</span>',
};
marks.superscript = {
open: '<sup>',
close: '</sup>',
};
marks.subscript = {
open: '<sub>',
close: '</sub>',
};
marks.text_color = {
open(state, mark, parent, index) {
return `<span style="color: ${mark.attrs.color};">`
},
close: '</span>',
};
marks.background_color = {
open(state, mark, parent, index) {
return `<span style="background-color: ${mark.attrs.color};">`
},
close: '</span>',
};
/**
* @param {MarkdownSerializerState} state
* @param {PmNode} node
*/
function writeNodeAsHtml(state, node) {
const html = docToHtml({content: [node]});
state.write(html);
state.ensureNewLine();
state.write('\n');
state.closeBlock();
}
/**
* @param {MarkdownSerializerState} state
* @param {PmNode} node
* @param {String} openTag
* @param {String} closeTag
*/
function wrapNodeWithHtml(state, node, openTag, closeTag) {
state.write(openTag);
state.ensureNewLine();
state.renderContent(node);
state.write(closeTag);
state.closeBlock();
state.ensureNewLine();
state.write('\n');
}
// Update serializers to just write out as HTML if we have an attribute
// or element that cannot be represented in commonmark without losing
// formatting or content.
for (const [nodeType, serializerFunction] of Object.entries(nodes)) {
nodes[nodeType] = function (state, node, parent, index) {
if (node.attrs.align || node.attrs.height || node.attrs.width) {
writeNodeAsHtml(state, node);
} else {
serializerFunction(state, node, parent, index);
}
}
}
const serializer = new MarkdownSerializer(nodes, marks);
export default serializer;

View file

@ -0,0 +1,62 @@
import crel from "crelt"
import {prefix} from "./menu-utils";
import {TextSelection} from "prosemirror-state"
import {expandSelectionToMark} from "../util";
class ColorPickerGrid {
constructor(markType, attrName, colors) {
this.markType = markType;
this.colors = colors
this.attrName = attrName;
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const colorElems = [];
for (const color of this.colors) {
const elem = crel("div", {class: prefix + "-color-grid-item", style: `background-color: ${color};`});
colorElems.push(elem);
}
const wrap = crel("div", {class: prefix + "-color-grid-container"}, colorElems);
wrap.addEventListener('click', event => {
if (event.target.classList.contains(prefix + "-color-grid-item")) {
const color = event.target.style.backgroundColor;
this.onColorSelect(view, color);
}
});
function update(state) {
return true;
}
return {dom: wrap, update}
}
onColorSelect(view, color) {
const attrs = {[this.attrName]: color};
const selection = view.state.selection;
const {from, to} = expandSelectionToMark(view.state, selection, this.markType);
const tr = view.state.tr;
const currentColorMarks = selection.$from.marksAcross(selection.$to) || [];
const activeRelevantMark = currentColorMarks.filter(mark => {
return mark.type === this.markType;
})[0];
const colorIsActive = activeRelevantMark && activeRelevantMark.attrs[this.attrName] === color;
tr.removeMark(from, to, this.markType);
if (!colorIsActive) {
tr.addMark(from, to, this.markType.create(attrs));
}
tr.setSelection(TextSelection.create(tr.doc, from, to));
view.dispatch(tr);
}
}
export default ColorPickerGrid;

View file

@ -0,0 +1,59 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, renderItems} from "./menu-utils";
import crel from "crelt";
import {getIcon, icons} from "./icons";
class DialogBox {
// :: ([MenuElement], ?Object)
// The following options are recognized:
//
// **`label`**`: string`
// : The label to show on the dialog.
// **`closer`**`: function`
// : The function to run when the dialog should close.
constructor(content, options) {
this.options = options || {};
this.content = Array.isArray(content) ? content : [content];
this.closeMouseDownListener = null;
this.wrap = null;
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const items = renderItems(this.content, view)
const titleText = crel("div", {class: prefix + "-dialog-title-text"}, this.options.label);
const titleClose = crel("button", {class: prefix + "-dialog-title-close primary-background", type: "button"}, getIcon(icons.close));
const titleContent = crel("div", {class: prefix + "-dialog-title"}, titleText, titleClose);
const dialog = crel("div", {class: prefix + "-dialog"}, titleContent,
crel("div", {class: prefix + "-dialog-content"}, items.dom));
const wrap = crel("div", {class: prefix + "-dialog-wrap"}, dialog);
this.wrap = wrap;
this.closeMouseDownListener = (event) => {
if (!dialog.contains(event.target) || titleClose.contains(event.target)) {
this.close();
}
}
wrap.addEventListener("click", this.closeMouseDownListener);
function update(state) {
let inner = items.update(state)
wrap.style.display = inner ? "" : "none"
return inner;
}
return {dom: wrap, update}
}
close() {
if (this.options.closer) {
this.options.closer();
}
}
}
export default DialogBox;

View file

@ -0,0 +1,51 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, renderItems} from "./menu-utils";
import crel from "crelt";
class DialogForm {
// :: ([MenuElement], ?Object)
// The following options are recognized:
//
// **`action`**`: function(FormData)`
// : The submission action to run when the form is submitted.
// **`canceler`**`: function`
// : The cancel action to run when the form is cancelled.
constructor(content, options) {
this.options = options || {};
this.content = Array.isArray(content) ? content : [content];
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const items = renderItems(this.content, view)
const formButtonCancel = crel("button", {class: prefix + "-dialog-button", type: "button"}, "Cancel");
const formButtonSave = crel("button", {class: prefix + "-dialog-button", type: "submit"}, "Save");
const footer = crel("div", {class: prefix + "-dialog-footer"}, formButtonCancel, formButtonSave);
const form = crel("form", {class: prefix + "-dialog-form", action: '#'}, items.dom, footer);
form.addEventListener('submit', event => {
event.preventDefault();
if (this.options.action) {
this.options.action(new FormData(form));
}
});
formButtonCancel.addEventListener('click', event => {
if (this.options.canceler) {
this.options.canceler();
}
});
function update(state) {
return items.update(state);
}
return {dom: form, update}
}
}
export default DialogForm;

View file

@ -0,0 +1,42 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, randHtmlId} from "./menu-utils";
import crel from "crelt";
class DialogInput {
// :: (?Object)
// The following options are recognized:
//
// **`label`**`: string`
// : The label to show for the input.
// **`id`**`: string`
// : The id to use for this input
// **`attrs`**`: Object`
// : The attributes to add to the input element.
// **`value`**`: function(state) -> string`
// : The getter for the input value.
constructor(options) {
this.options = options || {};
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const id = randHtmlId();
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
const input = crel("input", inputAttrs);
const label = crel("label", {for: id}, this.options.label);
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, input);
const update = (state) => {
input.value = this.options.value(state);
return true;
}
return {dom: rowRap, update}
}
}
export default DialogInput;

View file

@ -0,0 +1,53 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, randHtmlId} from "./menu-utils";
import crel from "crelt";
class DialogRadioOptions {
/**
* Given inputOptions should be keyed by label, with values being values.
* Values of empty string will be treated as null.
* @param {Object} inputOptions
* @param {{label: string, id: string, attrs?: Object, value: function(PmEditorState): string|null}} options
*/
constructor(inputOptions, options) {
this.inputOptions = inputOptions;
this.options = options || {};
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const inputs = [];
const optionInputLabels = Object.keys(this.inputOptions).map(label => {
const inputAttrs = Object.assign({
type: "radio",
name: this.options.id,
value: this.inputOptions[label],
class: prefix + '-dialog-radio-option',
}, this.options.attrs || {});
const input = crel("input", inputAttrs);
inputs.push(input);
return crel("label", input, label);
});
const optionInputWrap = crel("div", {class: prefix + '-dialog-radio-option-wrap'}, optionInputLabels);
const label = crel("label", {}, this.options.label);
const rowRap = crel("div", {class: prefix + '-dialog-form-row'}, label, optionInputWrap);
const update = (state) => {
const value = this.options.value(state);
for (const input of inputs) {
input.checked = (input.value === value || (value === null && input.value === ""));
}
return true;
}
return {dom: rowRap, update}
}
}
export default DialogRadioOptions;

View file

@ -0,0 +1,42 @@
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
import {prefix, randHtmlId} from "./menu-utils";
import crel from "crelt";
class DialogTextArea {
// :: (?Object)
// The following options are recognized:
//
// **`label`**`: string`
// : The label to show for the input.
// **`id`**`: string`
// : The id to use for this input
// **`attrs`**`: Object`
// : The attributes to add to the input element.
// **`value`**`: function(state) -> string`
// : The getter for the input value.
constructor(options) {
this.options = options || {};
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const id = randHtmlId();
const inputAttrs = Object.assign({type: "text", name: this.options.id, id: this.options.id}, this.options.attrs || {})
const input = crel("textarea", inputAttrs);
const label = this.options.label ? crel("label", {for: id}, this.options.label) : null;
const rowRap = crel("div", {class: prefix + '-dialog-textarea-wrap'}, label, input);
const update = (state) => {
input.value = this.options.value(state);
return true;
}
return {dom: rowRap, update}
}
}
export default DialogTextArea;

View file

@ -0,0 +1,86 @@
import crel from "crelt"
import {prefix} from "./menu-utils";
import {insertTable} from "../commands";
class TableCreatorGrid {
constructor() {
this.size = 10;
this.label = null;
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const gridItems = [];
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
const elem = crel("div", {class: prefix + "-table-creator-grid-item"});
gridItems.push(elem);
elem.addEventListener('mouseenter', event => {
this.updateGridItemActiveStatus(elem, gridItems);
});
}
}
const gridWrap = crel("div", {
class: prefix + "-table-creator-grid",
style: `grid-template-columns: repeat(${this.size}, 14px);`,
}, gridItems);
gridWrap.addEventListener('mouseleave', event => {
this.updateGridItemActiveStatus(null, gridItems);
});
gridWrap.addEventListener('click', event => {
if (event.target.classList.contains(prefix + "-table-creator-grid-item")) {
const {x, y} = this.getPositionOfGridItem(event.target, gridItems);
insertTable(y + 1, x + 1, {
style: 'width: 100%;',
})(view.state, view.dispatch);
}
});
const gridLabel = crel("div", {class: prefix + "-table-creator-grid-label"});
this.label = gridLabel;
const wrap = crel("div", {class: prefix + "-table-creator-grid-container"}, [gridWrap, gridLabel]);
function update(state) {
return true;
}
return {dom: wrap, update}
}
/**
* @param {Element|null} newTarget
* @param {Element[]} gridItems
*/
updateGridItemActiveStatus(newTarget, gridItems) {
const {x: xPos, y: yPos} = this.getPositionOfGridItem(newTarget, gridItems);
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
const active = x <= xPos && y <= yPos;
const index = (y * this.size) + x;
gridItems[index].classList.toggle(prefix + "-table-creator-grid-item-active", active);
}
}
this.label.textContent = (xPos + yPos < 0) ? '' : `${xPos + 1} x ${yPos + 1}`;
}
/**
* @param {Element} gridItem
* @param {Element[]} gridItems
* @return {{x: number, y: number}}
*/
getPositionOfGridItem(gridItem, gridItems) {
const index = gridItems.indexOf(gridItem);
const y = Math.floor(index / this.size);
const x = index % this.size;
return {x, y};
}
}
export default TableCreatorGrid;

View file

@ -0,0 +1,162 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-menu
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
// :: Object
// A set of basic editor-related icons. Contains the properties
// `join`, `lift`, `selectParentNode`, `undo`, `redo`, `strong`, `em`,
// `code`, `link`, `bulletList`, `orderedList`, and `blockquote`, each
// holding an object that can be used as the `icon` option to
// `MenuItem`.
export const icons = {
undo: {
width: 24, height: 24,
path: "M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"
},
redo: {
width: 24, height: 24,
path: "M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"
},
strong: {
width: 24, height: 24,
path: "M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"
},
em: {
width: 24, height: 24,
path: "M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"
},
link: {
width: 24, height: 24,
path: "M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
},
bullet_list: {
width: 24, height: 24,
path: "M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"
},
ordered_list: {
width: 24, height: 24,
path: "M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"
},
task_list: {
width: 24, height: 24,
path: "M22,7h-9v2h9V7z M22,15h-9v2h9V15z M5.54,11L2,7.46l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,11z M5.54,19L2,15.46 l1.41-1.41l2.12,2.12l4.24-4.24l1.41,1.41L5.54,19z"
},
underline: {
width: 24, height: 24,
path: "M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"
},
strike: {
width: 24, height: 24,
path: "M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"
},
superscript: {
width: 24, height: 24,
path: "M22,7h-2v1h3v1h-4V7c0-0.55,0.45-1,1-1h2V5h-3V4h3c0.55,0,1,0.45,1,1v1C23,6.55,22.55,7,22,7z M5.88,20h2.66l3.4-5.42h0.12 l3.4,5.42h2.66l-4.65-7.27L17.81,6h-2.68l-3.07,4.99h-0.12L8.85,6H6.19l4.32,6.73L5.88,20z"
},
subscript: {
width: 24, height: 24,
path: "M22,18h-2v1h3v1h-4v-2c0-0.55,0.45-1,1-1h2v-1h-3v-1h3c0.55,0,1,0.45,1,1v1C23,17.55,22.55,18,22,18z M5.88,18h2.66 l3.4-5.42h0.12l3.4,5.42h2.66l-4.65-7.27L17.81,4h-2.68l-3.07,4.99h-0.12L8.85,4H6.19l4.32,6.73L5.88,18z"
},
text_color: {
width: 24, height: 24,
path: "M2,20h20v4H2V20z M5.49,17h2.42l1.27-3.58h5.65L16.09,17h2.42L13.25,3h-2.5L5.49,17z M9.91,11.39l2.03-5.79h0.12l2.03,5.79 H9.91z"
},
background_color: {
width: 24, height: 24,
path: "M16.56,8.94L7.62,0L6.21,1.41l2.38,2.38L3.44,8.94c-0.59,0.59-0.59,1.54,0,2.12l5.5,5.5C9.23,16.85,9.62,17,10,17 s0.77-0.15,1.06-0.44l5.5-5.5C17.15,10.48,17.15,9.53,16.56,8.94z M5.21,10L10,5.21L14.79,10H5.21z M19,11.5c0,0-2,2.17-2,3.5 c0,1.1,0.9,2,2,2s2-0.9,2-2C21,13.67,19,11.5,19,11.5z M2,20h20v4H2V20z"
},
align_left: {
width: 24, height: 24,
path: "M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z"
},
align_right: {
width: 24, height: 24,
path: "M3 21h18v-2H3v2zm6-4h12v-2H9v2zm-6-4h18v-2H3v2zm6-4h12V7H9v2zM3 3v2h18V3H3z"
},
align_center: {
width: 24, height: 24,
path: "M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z"
},
align_justify: {
width: 24, height: 24,
path: "M3 21h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18v-2H3v2zm0-4h18V7H3v2zm0-6v2h18V3H3z"
},
horizontal_rule: {
width: 24, height: 24,
path: "m 4,11 h 16 v 2 H 4 Z"
},
format_clear: {
width: 24, height: 24,
path: "M3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"
},
close: {
width: 24, height: 24,
path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z",
},
source_code: {
width: 24, height: 24,
path: "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z",
},
table: {
width: 24, height: 24,
path: "M20 2H4c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM8 20H4v-4h4v4zm0-6H4v-4h4v4zm0-6H4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4zm6 12h-4v-4h4v4zm0-6h-4v-4h4v4zm0-6h-4V4h4v4z",
},
iframe: {
width: 24, height: 24,
path: "m 22.71,18.43 c 0.03,-0.29 0.04,-0.58 0.01,-0.86 l 1.07,-0.85 c 0.1,-0.08 0.12,-0.21 0.06,-0.32 L 22.82,14.61 C 22.76,14.5 22.63,14.46 22.51,14.5 L 21.23,15 C 21,14.83 20.75,14.69 20.48,14.58 l -0.2,-1.36 C 20.26,13.09 20.16,13 20.03,13 h -2.07 c -0.12,0 -0.23,0.09 -0.25,0.21 l -0.2,1.36 c -0.26,0.11 -0.51,0.26 -0.74,0.42 l -1.28,-0.5 c -0.12,-0.05 -0.25,0 -0.31,0.11 l -1.03,1.79 c -0.06,0.11 -0.04,0.24 0.06,0.32 l 1.07,0.86 c -0.03,0.29 -0.04,0.58 -0.01,0.86 l -1.07,0.85 c -0.1,0.08 -0.12,0.21 -0.06,0.32 l 1.03,1.79 c 0.06,0.11 0.19,0.15 0.31,0.11 L 16.75,21 c 0.23,0.17 0.48,0.31 0.75,0.42 l 0.2,1.36 c 0.02,0.12 0.12,0.21 0.25,0.21 h 2.07 c 0.12,0 0.23,-0.09 0.25,-0.21 l 0.2,-1.36 c 0.26,-0.11 0.51,-0.26 0.74,-0.42 l 1.28,0.5 c 0.12,0.05 0.25,0 0.31,-0.11 l 1.03,-1.79 c 0.06,-0.11 0.04,-0.24 -0.06,-0.32 z M 19,19.5 c -0.83,0 -1.5,-0.67 -1.5,-1.5 0,-0.83 0.67,-1.5 1.5,-1.5 0.83,0 1.5,0.67 1.5,1.5 0,0.83 -0.67,1.5 -1.5,1.5 z M 15,12 9,8 v 8 z M 3,6 h 18 v 5 h 2 V 6 C 23,4.9 22.1,4 21,4 H 3 C 1.9,4 1,4.9 1,6 v 12 c 0,1.1 0.9,2 2,2 h 9 V 18 H 3 Z",
},
details: {
width: 24, height: 24,
path: "m 7,10 5,5 5,-5 z M 19,2.5 H 5 c -1.11,0 -2,0.9 -2,2 v 14 c 0,1.1 0.89,2 2,2 h 14 c 1.1,0 2,-0.9 2,-2 v -14 c 0,-1.1 -0.89,-2 -2,-2 z m 0,16 H 5 v -12 h 14 z",
}
};
const SVG = "http://www.w3.org/2000/svg"
const XLINK = "http://www.w3.org/1999/xlink"
const prefix = "ProseMirror-icon"
function hashPath(path) {
let hash = 0
for (let i = 0; i < path.length; i++)
hash = (((hash << 5) - hash) + path.charCodeAt(i)) | 0
return hash
}
export function getIcon(icon) {
let node = document.createElement("div")
node.className = prefix
if (icon.path) {
let name = "pm-icon-" + hashPath(icon.path).toString(16)
if (!document.getElementById(name)) buildSVG(name, icon)
let svg = node.appendChild(document.createElementNS(SVG, "svg"))
svg.style.width = (icon.width / icon.height) + "em"
let use = svg.appendChild(document.createElementNS(SVG, "use"))
use.setAttributeNS(XLINK, "href", /([^#]*)/.exec(document.location)[1] + "#" + name)
} else if (icon.dom) {
node.appendChild(icon.dom.cloneNode(true))
} else {
node.appendChild(document.createElement("span")).textContent = icon.text || ''
if (icon.css) node.firstChild.style.cssText = icon.css
}
return node
}
function buildSVG(name, data) {
let collection = document.getElementById(prefix + "-collection")
if (!collection) {
collection = document.createElementNS(SVG, "svg")
collection.id = prefix + "-collection"
collection.style.display = "none"
document.body.insertBefore(collection, document.body.firstChild)
}
let sym = document.createElementNS(SVG, "symbol")
sym.id = name
sym.setAttribute("viewBox", "0 0 " + data.width + " " + data.height)
let path = sym.appendChild(document.createElementNS(SVG, "path"))
path.setAttribute("d", data.path)
collection.appendChild(sym)
}

View file

@ -0,0 +1,207 @@
import {
MenuItem, Dropdown, DropdownSubmenu, renderGrouped, joinUpItem, liftItem, selectParentNodeItem,
undoItem, redoItem, wrapItem, blockTypeItem, setAttrItem, insertBlockBeforeItem,
} from "./menu"
import {icons} from "./icons";
import ColorPickerGrid from "./ColorPickerGrid";
import TableCreatorGrid from "./TableCreatorGrid";
import {toggleMark} from "prosemirror-commands";
import {menuBar} from "./menubar"
import schema from "../schema";
import {removeMarks} from "../commands";
import itemAnchorButtonItem from "./item-anchor-button";
import itemHtmlSourceButton from "./item-html-source-button";
import itemIframeButton from "./item-iframe-button";
function cmdItem(cmd, options) {
const passedOptions = {
label: options.title,
run: cmd
};
for (const prop in options) {
passedOptions[prop] = options[prop];
}
if ((!options.enable || options.enable === true) && !options.select) {
passedOptions[options.enable ? "enable" : "select"] = function (state) {
return cmd(state);
};
}
return new MenuItem(passedOptions)
}
function markActive(state, type) {
const ref = state.selection;
const from = ref.from;
const $from = ref.$from;
const to = ref.to;
const empty = ref.empty;
if (empty) {
return type.isInSet(state.storedMarks || $from.marks())
} else {
return state.doc.rangeHasMark(from, to, type)
}
}
function markItem(markType, options) {
const passedOptions = {
active: function active(state) {
return markActive(state, markType)
},
enable: true
};
for (const prop in options) {
passedOptions[prop] = options[prop];
}
return cmdItem(toggleMark(markType, passedOptions.attrs), passedOptions)
}
const inlineStyles = [
markItem(schema.marks.strong, {title: "Bold", icon: icons.strong}),
markItem(schema.marks.em, {title: "Italic", icon: icons.em}),
markItem(schema.marks.underline, {title: "Underline", icon: icons.underline}),
markItem(schema.marks.strike, {title: "Strikethrough", icon: icons.strike}),
markItem(schema.marks.superscript, {title: "Superscript", icon: icons.superscript}),
markItem(schema.marks.subscript, {title: "Subscript", icon: icons.subscript}),
];
const formats = [
blockTypeItem(schema.nodes.heading, {
label: "Header Large",
attrs: {level: 2}
}),
blockTypeItem(schema.nodes.heading, {
label: "Header Medium",
attrs: {level: 3}
}),
blockTypeItem(schema.nodes.heading, {
label: "Header Small",
attrs: {level: 4}
}),
blockTypeItem(schema.nodes.heading, {
label: "Header Tiny",
attrs: {level: 5}
}),
blockTypeItem(schema.nodes.paragraph, {
label: "Paragraph",
attrs: {}
}),
markItem(schema.marks.code, {
label: "Inline Code",
attrs: {}
}),
new DropdownSubmenu([
blockTypeItem(schema.nodes.callout, {
label: "Info Callout",
attrs: {type: 'info'}
}),
blockTypeItem(schema.nodes.callout, {
label: "Danger Callout",
attrs: {type: 'danger'}
}),
blockTypeItem(schema.nodes.callout, {
label: "Success Callout",
attrs: {type: 'success'}
}),
blockTypeItem(schema.nodes.callout, {
label: "Warning Callout",
attrs: {type: 'warning'}
})
], { label: 'Callouts' }),
];
const alignments = [
setAttrItem('align', 'left', {
icon: icons.align_left
}),
setAttrItem('align', 'center', {
icon: icons.align_center
}),
setAttrItem('align', 'right', {
icon: icons.align_right
}),
setAttrItem('align', 'justify', {
icon: icons.align_justify
}),
];
const colorOptions = ["#000000","#993300","#333300","#003300","#003366","#000080","#333399","#333333","#800000","#FF6600","#808000","#008000","#008080","#0000FF","#666699","#808080","#FF0000","#FF9900","#99CC00","#339966","#33CCCC","#3366FF","#800080","#999999","#FF00FF","#FFCC00","#FFFF00","#00FF00","#00FFFF","#00CCFF","#993366","#FFFFFF","#FF99CC","#FFCC99","#FFFF99","#CCFFCC","#CCFFFF","#99CCFF","#CC99FF"];
const colors = [
new DropdownSubmenu([
new ColorPickerGrid(schema.marks.text_color, 'color', colorOptions),
], {icon: icons.text_color}),
new DropdownSubmenu([
new ColorPickerGrid(schema.marks.background_color, 'color', colorOptions),
], {icon: icons.background_color}),
];
const lists = [
wrapItem(schema.nodes.bullet_list, {
title: "Bullet List",
icon: icons.bullet_list,
}),
wrapItem(schema.nodes.ordered_list, {
title: "Ordered List",
icon: icons.ordered_list,
}),
];
const inserts = [
itemAnchorButtonItem(),
insertBlockBeforeItem(schema.nodes.horizontal_rule, {
title: "Horizontal Rule",
icon: icons.horizontal_rule,
}),
new DropdownSubmenu([
new TableCreatorGrid()
], {icon: icons.table}),
itemIframeButton(),
wrapItem(schema.nodes.details, {
title: "Dropdown Block",
icon: icons.details,
})
];
const utilities = [
new MenuItem({
title: 'Clear Formatting',
icon: icons.format_clear,
run: removeMarks(),
enable: state => true,
}),
itemHtmlSourceButton(),
];
const menu = menuBar({
floating: false,
content: [
[undoItem, redoItem],
[new DropdownSubmenu(formats, { label: 'Formats' })],
inlineStyles,
colors,
alignments,
lists,
inserts,
utilities,
],
});
export default menu;
// !! This module defines a number of building blocks for ProseMirror
// menus, along with a [menu bar](#menu.menuBar) implementation.
// MenuElement:: interface
// The types defined in this module aren't the only thing you can
// display in your menu. Anything that conforms to this interface can
// be put into a menu structure.
//
// render:: (pm: EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Render the element for display in the menu. Must return a DOM
// element and a function that can be used to update the element to
// a new state. The `update` function will return false if the
// update hid the entire element.

View file

@ -0,0 +1,120 @@
import DialogBox from "./DialogBox";
import DialogForm from "./DialogForm";
import DialogInput from "./DialogInput";
import DialogRadioOptions from "./DialogRadioOptions";
import schema from "../schema";
import {MenuItem} from "./menu";
import {icons} from "./icons";
import {expandSelectionToMark, nullifyEmptyValues} from "../util";
/**
* @param {PmMarkType} markType
* @param {String} attribute
* @return {(function(PmEditorState): (string|null))}
*/
function getMarkAttribute(markType, attribute) {
return function (state) {
const marks = state.selection.$head.marks();
for (const mark of marks) {
if (mark.type === markType) {
return mark.attrs[attribute];
}
}
return null;
};
}
/**
* @param {(function(FormData))} submitter
* @param {Function} closer
* @return {DialogBox}
*/
function getLinkDialog(submitter, closer) {
return new DialogBox([
new DialogForm([
new DialogInput({
label: 'URL',
id: 'href',
value: getMarkAttribute(schema.marks.link, 'href'),
}),
new DialogInput({
label: 'Hover Label',
id: 'title',
value: getMarkAttribute(schema.marks.link, 'title'),
}),
new DialogRadioOptions({
"Same tab or window": "",
"New tab or window": "_blank",
}, {
label: 'Behaviour',
id: 'target',
value: getMarkAttribute(schema.marks.link, 'target'),
})
], {
canceler: closer,
action: submitter,
}),
], {
label: 'Insert Link',
closer: closer,
});
}
/**
* @param {FormData} formData
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @return {boolean}
*/
function applyLink(formData, state, dispatch) {
const selection = state.selection;
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
if (!dispatch) return true;
const tr = state.tr;
const {from, to} = expandSelectionToMark(state, selection, schema.marks.link);
if (attrs.href) {
tr.addMark(from, to, schema.marks.link.create(attrs));
} else {
tr.removeMark(from, to, schema.marks.link);
}
dispatch(tr);
return true;
}
/**
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @param {PmView} view
* @param {Event} e
*/
function onPress(state, dispatch, view, e) {
const dialog = getLinkDialog((data) => {
applyLink(data, state, dispatch);
dom.remove();
}, () => {
dom.remove();
})
const {dom, update} = dialog.render(view);
update(state);
document.body.appendChild(dom);
}
/**
* @return {MenuItem}
*/
function anchorButtonItem() {
return new MenuItem({
title: "Insert/Edit Anchor Link",
run: onPress,
enable: state => true,
icon: icons.link,
});
}
export default anchorButtonItem;

View file

@ -0,0 +1,87 @@
import DialogBox from "./DialogBox";
import DialogForm from "./DialogForm";
import DialogTextArea from "./DialogTextArea";
import {MenuItem} from "./menu";
import {icons} from "./icons";
import {htmlToDoc, stateToHtml} from "../util";
/**
* @param {(function(FormData))} submitter
* @param {Function} closer
* @return {DialogBox}
*/
function getLinkDialog(submitter, closer) {
return new DialogBox([
new DialogForm([
new DialogTextArea({
id: 'source',
value: stateToHtml,
attrs: {
rows: 10,
cols: 50,
}
}),
], {
canceler: closer,
action: submitter,
}),
], {
label: 'View/Edit HTML Source',
closer: closer,
});
}
/**
* @param {FormData} formData
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @return {boolean}
*/
function replaceEditorHtml(formData, state, dispatch) {
const html = formData.get('source');
if (dispatch) {
const tr = state.tr;
const newDoc = htmlToDoc(html);
tr.replaceWith(0, state.doc.content.size, newDoc.content);
dispatch(tr);
}
return true;
}
/**
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @param {PmView} view
* @param {Event} e
*/
function onPress(state, dispatch, view, e) {
const dialog = getLinkDialog((data) => {
replaceEditorHtml(data, state, dispatch);
dom.remove();
}, () => {
dom.remove();
})
const {dom, update} = dialog.render(view);
update(state);
document.body.appendChild(dom);
}
/**
* @return {MenuItem}
*/
function htmlSourceButtonItem() {
return new MenuItem({
title: "View HTML Source",
run: onPress,
enable: state => true,
icon: icons.source_code,
});
}
export default htmlSourceButtonItem;

View file

@ -0,0 +1,115 @@
import DialogBox from "./DialogBox";
import DialogForm from "./DialogForm";
import DialogInput from "./DialogInput";
import schema from "../schema";
import {MenuItem} from "./menu";
import {icons} from "./icons";
import {nullifyEmptyValues} from "../util";
/**
* @param {PmNodeType} nodeType
* @param {String} attribute
* @return {(function(PmEditorState): (string|null))}
*/
function getNodeAttribute(nodeType, attribute) {
return function (state) {
const node = state.selection.node;
if (node && node.type === nodeType) {
return node.attrs[attribute];
}
return null;
};
}
/**
* @param {(function(FormData))} submitter
* @param {Function} closer
* @return {DialogBox}
*/
function getLinkDialog(submitter, closer) {
return new DialogBox([
new DialogForm([
new DialogInput({
label: 'Source URL',
id: 'src',
value: getNodeAttribute(schema.nodes.iframe, 'src'),
}),
new DialogInput({
label: 'Hover Label',
id: 'title',
value: getNodeAttribute(schema.nodes.iframe, 'title'),
}),
new DialogInput({
label: 'Width',
id: 'width',
value: getNodeAttribute(schema.nodes.iframe, 'width'),
}),
new DialogInput({
label: 'Height',
id: 'height',
value: getNodeAttribute(schema.nodes.iframe, 'height'),
}),
], {
canceler: closer,
action: submitter,
}),
], {
label: 'Insert Embedded Content',
closer: closer,
});
}
/**
* @param {FormData} formData
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @return {boolean}
*/
function applyIframe(formData, state, dispatch) {
const attrs = nullifyEmptyValues(Object.fromEntries(formData));
if (!dispatch) return true;
const tr = state.tr;
const currentNodeAttrs = state.selection?.nodes?.attrs || {};
const newAttrs = Object.assign({}, currentNodeAttrs, attrs);
tr.replaceSelectionWith(schema.nodes.iframe.create(newAttrs));
dispatch(tr);
return true;
}
/**
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
* @param {PmView} view
* @param {Event} e
*/
function onPress(state, dispatch, view, e) {
const dialog = getLinkDialog((data) => {
applyIframe(data, state, dispatch);
dom.remove();
}, () => {
dom.remove();
})
const {dom, update} = dialog.render(view);
update(state);
document.body.appendChild(dom);
}
/**
* @return {MenuItem}
*/
function iframeButtonItem() {
return new MenuItem({
title: "Embed Content",
run: onPress,
enable: state => true,
active: state => (state.selection.node || {type: ''}).type === schema.nodes.iframe,
icon: icons.iframe,
});
}
export default iframeButtonItem;

View file

@ -0,0 +1,39 @@
import crel from "crelt";
export const prefix = "ProseMirror-menu";
export function renderDropdownItems(items, view) {
let rendered = [], updates = []
for (let i = 0; i < items.length; i++) {
let {dom, update} = items[i].render(view)
rendered.push(crel("div", {class: prefix + "-dropdown-item"}, dom))
updates.push(update)
}
return {dom: rendered, update: combineUpdates(updates, rendered)}
}
export function renderItems(items, view) {
let rendered = [], updates = []
for (let i = 0; i < items.length; i++) {
let {dom, update} = items[i].render(view)
rendered.push(dom);
updates.push(update)
}
return {dom: rendered, update: combineUpdates(updates, rendered)}
}
export function combineUpdates(updates, nodes) {
return state => {
let something = false
for (let i = 0; i < updates.length; i++) {
let up = updates[i](state)
nodes[i].style.display = up ? "" : "none"
if (up) something = true
}
return something
}
}
export function randHtmlId() {
return Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 9);
}

View file

@ -0,0 +1,419 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-menu
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
import crel from "crelt"
import {lift, joinUp, selectParentNode, wrapIn, setBlockType, toggleMark} from "prosemirror-commands"
import {undo, redo} from "prosemirror-history"
import {setBlockAttr, insertBlockBefore} from "../commands";
import {renderDropdownItems, combineUpdates} from "./menu-utils";
import {getIcon, icons} from "./icons"
import {prefix} from "./menu-utils";
// ::- An icon or label that, when clicked, executes a command.
export class MenuItem {
// :: (MenuItemSpec)
constructor(spec) {
// :: MenuItemSpec
// The spec used to create the menu item.
this.spec = spec
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the icon according to its [display
// spec](#menu.MenuItemSpec.display), and adds an event handler which
// executes the command when the representation is clicked.
render(view) {
let spec = this.spec
let dom = spec.render ? spec.render(view)
: spec.icon ? getIcon(spec.icon)
: spec.label ? crel("div", null, translate(view, spec.label))
: null
if (!dom) throw new RangeError("MenuItem without icon or label property")
if (spec.title) {
const title = (typeof spec.title === "function" ? spec.title(view.state) : spec.title)
dom.setAttribute("title", translate(view, title))
}
if (spec.class) dom.classList.add(spec.class)
if (spec.css) dom.style.cssText += spec.css
dom.addEventListener("mousedown", e => {
e.preventDefault()
if (!dom.classList.contains(prefix + "-disabled"))
spec.run(view.state, view.dispatch, view, e)
})
function update(state) {
if (spec.select) {
let selected = spec.select(state)
dom.style.display = selected ? "" : "none"
if (!selected) return false
}
let enabled = true
if (spec.enable) {
enabled = spec.enable(state) || false
setClass(dom, prefix + "-disabled", !enabled)
}
if (spec.active) {
let active = enabled && spec.active(state) || false
setClass(dom, prefix + "-active", active)
}
return true
}
return {dom, update}
}
}
function translate(view, text) {
return view._props.translate ? view._props.translate(text) : text
}
// MenuItemSpec:: interface
// The configuration object passed to the `MenuItem` constructor.
//
// run:: (EditorState, (Transaction), EditorView, dom.Event)
// The function to execute when the menu item is activated.
//
// select:: ?(EditorState) → bool
// Optional function that is used to determine whether the item is
// appropriate at the moment. Deselected items will be hidden.
//
// enable:: ?(EditorState) → bool
// Function that is used to determine if the item is enabled. If
// given and returning false, the item will be given a disabled
// styling.
//
// active:: ?(EditorState) → bool
// A predicate function to determine whether the item is 'active' (for
// example, the item for toggling the strong mark might be active then
// the cursor is in strong text).
//
// render:: ?(EditorView) → dom.Node
// A function that renders the item. You must provide either this,
// [`icon`](#menu.MenuItemSpec.icon), or [`label`](#MenuItemSpec.label).
//
// icon:: ?Object
// Describes an icon to show for this item. The object may specify
// an SVG icon, in which case its `path` property should be an [SVG
// path
// spec](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d),
// and `width` and `height` should provide the viewbox in which that
// path exists. Alternatively, it may have a `text` property
// specifying a string of text that makes up the icon, with an
// optional `css` property giving additional CSS styling for the
// text. _Or_ it may contain `dom` property containing a DOM node.
//
// label:: ?string
// Makes the item show up as a text label. Mostly useful for items
// wrapped in a [drop-down](#menu.Dropdown) or similar menu. The object
// should have a `label` property providing the text to display.
//
// title:: ?union<string, (EditorState) → string>
// Defines DOM title (mouseover) text for the item.
//
// class:: ?string
// Optionally adds a CSS class to the item's DOM representation.
//
// css:: ?string
// Optionally adds a string of inline CSS to the item's DOM
// representation.
let lastMenuEvent = {time: 0, node: null}
function markMenuEvent(e) {
lastMenuEvent.time = Date.now()
lastMenuEvent.node = e.target
}
function isMenuEvent(wrapper) {
return Date.now() - 100 < lastMenuEvent.time &&
lastMenuEvent.node && wrapper.contains(lastMenuEvent.node)
}
// ::- A drop-down menu, displayed as a label with a downwards-pointing
// triangle to the right of it.
export class Dropdown {
// :: ([MenuElement], ?Object)
// Create a dropdown wrapping the elements. Options may include
// the following properties:
//
// **`label`**`: string`
// : The label to show on the drop-down control.
//
// **`title`**`: string`
// : Sets the
// [`title`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/title)
// attribute given to the menu control.
//
// **`class`**`: string`
// : When given, adds an extra CSS class to the menu control.
//
// **`css`**`: string`
// : When given, adds an extra set of CSS styles to the menu control.
constructor(content, options) {
this.options = options || {}
this.content = Array.isArray(content) ? content : [content]
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState)}
// Render the dropdown menu and sub-items.
render(view) {
let content = renderDropdownItems(this.content, view)
let label = crel("div", {class: prefix + "-dropdown " + (this.options.class || ""),
style: this.options.css},
translate(view, this.options.label))
if (this.options.title) label.setAttribute("title", translate(view, this.options.title))
let wrap = crel("div", {class: prefix + "-dropdown-wrap"}, label)
let open = null, listeningOnClose = null
let close = () => {
if (open && open.close()) {
open = null
window.removeEventListener("mousedown", listeningOnClose)
}
}
label.addEventListener("mousedown", e => {
e.preventDefault()
markMenuEvent(e)
if (open) {
close()
} else {
open = this.expand(wrap, content.dom)
window.addEventListener("mousedown", listeningOnClose = () => {
if (!isMenuEvent(wrap)) close()
})
}
})
function update(state) {
let inner = content.update(state)
wrap.style.display = inner ? "" : "none"
return inner
}
return {dom: wrap, update}
}
expand(dom, items) {
let menuDOM = crel("div", {class: prefix + "-dropdown-menu " + (this.options.class || "")}, items)
let done = false
function close() {
if (done) return
done = true
dom.removeChild(menuDOM)
return true
}
dom.appendChild(menuDOM)
return {close, node: menuDOM}
}
}
// ::- Represents a submenu wrapping a group of elements that start
// hidden and expand to the right when hovered over or tapped.
export class DropdownSubmenu {
// :: ([MenuElement], ?Object)
// Creates a submenu for the given group of menu elements. The
// following options are recognized:
//
// **`label`**`: string`
// : The label to show on the submenu.
constructor(content, options) {
this.options = options || {}
this.content = Array.isArray(content) ? content : [content]
}
// :: (EditorView) → {dom: dom.Node, update: (EditorState) → bool}
// Renders the submenu.
render(view) {
const items = renderDropdownItems(this.content, view)
const handleContent = this.options.icon ? getIcon(this.options.icon) : crel("div", {class: prefix + "-submenu-label"}, translate(view, this.options.label));
const wrap = crel("div", {class: prefix + "-submenu-wrap"}, handleContent,
crel("div", {class: prefix + "-submenu"}, items.dom))
let listeningOnClose = null
handleContent.addEventListener("mousedown", e => {
e.preventDefault()
markMenuEvent(e)
setClass(wrap, prefix + "-submenu-wrap-active")
if (!listeningOnClose)
window.addEventListener("mousedown", listeningOnClose = () => {
if (!isMenuEvent(wrap)) {
wrap.classList.remove(prefix + "-submenu-wrap-active")
window.removeEventListener("mousedown", listeningOnClose)
listeningOnClose = null
}
})
})
function update(state) {
let inner = items.update(state)
wrap.style.display = inner ? "" : "none"
return inner
}
return {dom: wrap, update}
}
}
// :: (EditorView, [[MenuElement]]) → {dom: dom.DocumentFragment, update: (EditorState) → bool}
// Render the given, possibly nested, array of menu elements into a
// document fragment, placing separators between them (and ensuring no
// superfluous separators appear when some of the groups turn out to
// be empty).
export function renderGrouped(view, content) {
let result = document.createDocumentFragment()
let updates = [], separators = []
for (let i = 0; i < content.length; i++) {
let items = content[i], localUpdates = [], localNodes = []
for (let j = 0; j < items.length; j++) {
let {dom, update} = items[j].render(view)
let span = crel("span", {class: prefix + "item"}, dom)
result.appendChild(span)
localNodes.push(span)
localUpdates.push(update)
}
if (localUpdates.length) {
updates.push(combineUpdates(localUpdates, localNodes))
if (i < content.length - 1)
separators.push(result.appendChild(separator()))
}
}
function update(state) {
let something = false, needSep = false
for (let i = 0; i < updates.length; i++) {
let hasContent = updates[i](state)
if (i) separators[i - 1].style.display = needSep && hasContent ? "" : "none"
needSep = hasContent
if (hasContent) something = true
}
return something
}
return {dom: result, update}
}
function separator() {
return crel("span", {class: prefix + "separator"})
}
// :: MenuItem
// Menu item for the `joinUp` command.
export const joinUpItem = new MenuItem({
title: "Join with above block",
run: joinUp,
select: state => joinUp(state),
icon: icons.join
})
// :: MenuItem
// Menu item for the `lift` command.
export const liftItem = new MenuItem({
title: "Lift out of enclosing block",
run: lift,
select: state => lift(state),
icon: icons.lift
})
// :: MenuItem
// Menu item for the `selectParentNode` command.
export const selectParentNodeItem = new MenuItem({
title: "Select parent node",
run: selectParentNode,
select: state => selectParentNode(state),
icon: icons.selectParentNode
})
// :: MenuItem
// Menu item for the `undo` command.
export let undoItem = new MenuItem({
title: "Undo last change",
run: undo,
enable: state => undo(state),
icon: icons.undo
})
// :: MenuItem
// Menu item for the `redo` command.
export let redoItem = new MenuItem({
title: "Redo last undone change",
run: redo,
enable: state => redo(state),
icon: icons.redo
})
// :: (NodeType, Object) → MenuItem
// Build a menu item for wrapping the selection in a given node type.
// Adds `run` and `select` properties to the ones present in
// `options`. `options.attrs` may be an object or a function.
export function wrapItem(nodeType, options) {
let passedOptions = {
run(state, dispatch) {
// FIXME if (options.attrs instanceof Function) options.attrs(state, attrs => wrapIn(nodeType, attrs)(state))
return wrapIn(nodeType, options.attrs)(state, dispatch)
},
select(state) {
return wrapIn(nodeType, options.attrs instanceof Function ? null : options.attrs)(state)
}
}
for (let prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions)
}
// :: (NodeType, Object) → MenuItem
// Build a menu item for changing the type of the textblock around the
// selection to the given type. Provides `run`, `active`, and `select`
// properties. Others must be given in `options`. `options.attrs` may
// be an object to provide the attributes for the textblock node.
export function blockTypeItem(nodeType, options) {
let command = setBlockType(nodeType, options.attrs)
let passedOptions = {
run: command,
enable(state) { return command(state) },
active(state) {
let {$from, to, node} = state.selection
if (node) return node.hasMarkup(nodeType, options.attrs)
return to <= $from.end() && $from.parent.hasMarkup(nodeType, options.attrs)
}
}
for (let prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions)
}
export function setAttrItem(attrName, attrValue, options) {
const command = setBlockAttr(attrName, attrValue);
const passedOptions = {
run: command,
enable(state) { return command(state) },
active(state) {
const {$from, to, node} = state.selection
if (node) return node.attrs[attrValue] === attrValue;
return to <= $from.end() && $from.parent.attrs[attrValue] === attrValue;
}
}
for (const prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions)
}
export function insertBlockBeforeItem(blockType, options) {
const command = insertBlockBefore(blockType);
const passedOptions = {
run: command,
enable(state) { return command(state) },
active(state) {
return false;
}
}
for (const prop in options) passedOptions[prop] = options[prop]
return new MenuItem(passedOptions);
}
// Work around classList.toggle being broken in IE11
function setClass(dom, cls, on) {
if (on) dom.classList.add(cls)
else dom.classList.remove(cls)
}

View file

@ -0,0 +1,163 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-menu
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
import crel from "crelt"
import {Plugin} from "prosemirror-state"
import {renderGrouped} from "./menu"
const prefix = "ProseMirror-menubar"
function isIOS() {
if (typeof navigator == "undefined") return false
let agent = navigator.userAgent
return !/Edge\/\d/.test(agent) && /AppleWebKit/.test(agent) && /Mobile\/\w+/.test(agent)
}
// :: (Object) → Plugin
// A plugin that will place a menu bar above the editor. Note that
// this involves wrapping the editor in an additional `<div>`.
//
// options::-
// Supports the following options:
//
// content:: [[MenuElement]]
// Provides the content of the menu, as a nested array to be
// passed to `renderGrouped`.
//
// floating:: ?bool
// Determines whether the menu floats, i.e. whether it sticks to
// the top of the viewport when the editor is partially scrolled
// out of view.
export function menuBar(options) {
return new Plugin({
view(editorView) { return new MenuBarView(editorView, options) }
})
}
class MenuBarView {
constructor(editorView, options) {
this.editorView = editorView
this.options = options
this.wrapper = crel("div", {class: prefix + "-wrapper"})
this.menu = this.wrapper.appendChild(crel("div", {class: prefix}))
this.menu.className = prefix
this.spacer = null
if (editorView.dom.parentNode)
editorView.dom.parentNode.replaceChild(this.wrapper, editorView.dom)
this.wrapper.appendChild(editorView.dom)
this.maxHeight = 0
this.widthForMaxHeight = 0
this.floating = false
let {dom, update} = renderGrouped(this.editorView, this.options.content)
this.contentUpdate = update
this.menu.appendChild(dom)
this.update()
if (options.floating && !isIOS()) {
this.updateFloat()
let potentialScrollers = getAllWrapping(this.wrapper)
this.scrollFunc = (e) => {
let root = this.editorView.root
if (!(root.body || root).contains(this.wrapper)) {
potentialScrollers.forEach(el => el.removeEventListener("scroll", this.scrollFunc))
} else {
this.updateFloat(e.target.getBoundingClientRect && e.target)
}
}
potentialScrollers.forEach(el => el.addEventListener('scroll', this.scrollFunc))
}
}
update() {
this.contentUpdate(this.editorView.state)
if (this.floating) {
this.updateScrollCursor()
} else {
if (this.menu.offsetWidth != this.widthForMaxHeight) {
this.widthForMaxHeight = this.menu.offsetWidth
this.maxHeight = 0
}
if (this.menu.offsetHeight > this.maxHeight) {
this.maxHeight = this.menu.offsetHeight
this.menu.style.minHeight = this.maxHeight + "px"
}
}
}
updateScrollCursor() {
let selection = this.editorView.root.getSelection()
if (!selection.focusNode) return
let rects = selection.getRangeAt(0).getClientRects()
let selRect = rects[selectionIsInverted(selection) ? 0 : rects.length - 1]
if (!selRect) return
let menuRect = this.menu.getBoundingClientRect()
if (selRect.top < menuRect.bottom && selRect.bottom > menuRect.top) {
let scrollable = findWrappingScrollable(this.wrapper)
if (scrollable) scrollable.scrollTop -= (menuRect.bottom - selRect.top)
}
}
updateFloat(scrollAncestor) {
let parent = this.wrapper, editorRect = parent.getBoundingClientRect(),
top = scrollAncestor ? Math.max(0, scrollAncestor.getBoundingClientRect().top) : 0
if (this.floating) {
if (editorRect.top >= top || editorRect.bottom < this.menu.offsetHeight + 10) {
this.floating = false
this.menu.style.position = this.menu.style.left = this.menu.style.top = this.menu.style.width = ""
this.menu.style.display = ""
this.spacer.parentNode.removeChild(this.spacer)
this.spacer = null
} else {
let border = (parent.offsetWidth - parent.clientWidth) / 2
this.menu.style.left = (editorRect.left + border) + "px"
this.menu.style.display = (editorRect.top > window.innerHeight ? "none" : "")
if (scrollAncestor) this.menu.style.top = top + "px"
}
} else {
if (editorRect.top < top && editorRect.bottom >= this.menu.offsetHeight + 10) {
this.floating = true
let menuRect = this.menu.getBoundingClientRect()
this.menu.style.left = menuRect.left + "px"
this.menu.style.width = menuRect.width + "px"
if (scrollAncestor) this.menu.style.top = top + "px"
this.menu.style.position = "fixed"
this.spacer = crel("div", {class: prefix + "-spacer", style: `height: ${menuRect.height}px`})
parent.insertBefore(this.spacer, this.menu)
}
}
}
destroy() {
if (this.wrapper.parentNode)
this.wrapper.parentNode.replaceChild(this.editorView.dom, this.wrapper)
}
}
// Not precise, but close enough
function selectionIsInverted(selection) {
if (selection.anchorNode == selection.focusNode) return selection.anchorOffset > selection.focusOffset
return selection.anchorNode.compareDocumentPosition(selection.focusNode) == Node.DOCUMENT_POSITION_FOLLOWING
}
function findWrappingScrollable(node) {
for (let cur = node.parentNode; cur; cur = cur.parentNode)
if (cur.scrollHeight > cur.clientHeight) return cur
}
function getAllWrapping(node) {
let res = [window]
for (let cur = node.parentNode; cur; cur = cur.parentNode)
res.push(cur)
return res
}

View file

@ -0,0 +1,26 @@
class IframeView {
/**
* @param {PmNode} node
* @param {PmView} view
* @param {(function(): number)} getPos
*/
constructor(node, view, getPos) {
this.dom = document.createElement('div');
this.dom.classList.add('ProseMirror-iframewrap');
this.iframe = document.createElement("iframe");
for (const [key, value] of Object.entries(node.attrs)) {
if (value) {
this.iframe.setAttribute(key, value);
}
}
this.dom.appendChild(this.iframe);
}
stopEvent() {
return false;
}
}
export default IframeView;

View file

@ -0,0 +1,197 @@
import {positionHandlesAtCorners, removeHandles, renderHandlesAtCorners} from "./node-view-utils";
import {NodeSelection} from "prosemirror-state";
class ImageView {
/**
* @param {PmNode} node
* @param {PmView} view
* @param {(function(): number)} getPos
*/
constructor(node, view, getPos) {
this.dom = document.createElement('div');
this.dom.classList.add('ProseMirror-imagewrap');
this.image = document.createElement("img");
this.image.src = node.attrs.src;
this.image.alt = node.attrs.alt;
if (node.attrs.width) {
this.image.width = node.attrs.width;
}
if (node.attrs.height) {
this.image.height = node.attrs.height;
}
this.dom.appendChild(this.image);
this.handles = [];
this.handleDragStartInfo = null;
this.handleDragMoveDimensions = null;
this.removeHandlesListener = this.removeHandlesListener.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.dom.addEventListener("click", event => {
this.showHandles();
});
// Show handles if selected
if (view.state.selection.node === node) {
window.setTimeout(() => {
this.showHandles();
}, 10);
}
this.updateImageDimensions = function (width, height) {
const attrs = Object.assign({}, node.attrs, {width, height});
let tr = view.state.tr;
const position = getPos();
tr = tr.setNodeMarkup(position, null, attrs)
tr = tr.setSelection(NodeSelection.create(tr.doc, position));
view.dispatch(tr);
};
}
showHandles() {
if (this.handles.length === 0) {
this.image.dataset.showHandles = 'true';
window.addEventListener('click', this.removeHandlesListener);
this.handles = renderHandlesAtCorners(this.image);
for (const handle of this.handles) {
handle.addEventListener('mousedown', this.handleMouseDown);
}
}
}
removeHandlesListener(event) {
if (!this.dom.contains(event.target)) {
this.removeHandles();
this.handles = [];
}
}
removeHandles() {
removeHandles(this.handles);
window.removeEventListener('click', this.removeHandlesListener);
delete this.image.dataset.showHandles;
}
stopEvent() {
return false;
}
/**
* @param {MouseEvent} event
*/
handleMouseDown(event) {
event.preventDefault();
const imageBounds = this.image.getBoundingClientRect();
const handle = event.target;
this.handleDragStartInfo = {
x: event.screenX,
y: event.screenY,
ratio: imageBounds.width / imageBounds.height,
bounds: imageBounds,
handleX: handle.dataset.x,
handleY: handle.dataset.y,
};
this.createDragDummy(imageBounds);
this.dom.appendChild(this.dragDummy);
window.addEventListener('mousemove', this.handleMouseMove);
window.addEventListener('mouseup', this.handleMouseUp);
}
/**
* @param {DOMRect} bounds
*/
createDragDummy(bounds) {
this.dragDummy = this.image.cloneNode();
this.dragDummy.style.opacity = '0.5';
this.dragDummy.classList.add('ProseMirror-dragdummy');
this.dragDummy.style.width = bounds.width + 'px';
this.dragDummy.style.height = bounds.height + 'px';
}
/**
* @param {MouseEvent} event
*/
handleMouseUp(event) {
if (this.handleDragMoveDimensions) {
const {width, height} = this.handleDragMoveDimensions;
this.updateImageDimensions(String(width), String(height));
}
window.removeEventListener('mousemove', this.handleMouseMove);
window.removeEventListener('mouseup', this.handleMouseUp);
this.handleDragStartInfo = null;
this.handleDragMoveDimensions = null;
this.dragDummy.remove();
positionHandlesAtCorners(this.image, this.handles);
}
/**
* @param {MouseEvent} event
*/
handleMouseMove(event) {
const originalBounds = this.handleDragStartInfo.bounds;
// Calculate change in x & y, flip amounts depending on handle
let xChange = event.screenX - this.handleDragStartInfo.x;
if (this.handleDragStartInfo.handleX === 'left') {
xChange = -xChange;
}
let yChange = event.screenY - this.handleDragStartInfo.y;
if (this.handleDragStartInfo.handleY === 'top') {
yChange = -yChange;
}
// Prevent images going too small or into negative bounds
if (originalBounds.width + xChange < 10) {
xChange = -originalBounds.width + 10;
}
if (originalBounds.height + yChange < 10) {
yChange = -originalBounds.height + 10;
}
// Choose the larger dimension change and align the other to keep
// image aspect ratio, aligning growth/reduction direction
if (Math.abs(xChange) > Math.abs(yChange)) {
yChange = Math.floor(xChange * this.handleDragStartInfo.ratio);
if (yChange * xChange < 0) {
yChange = -yChange;
}
} else {
xChange = Math.floor(yChange / this.handleDragStartInfo.ratio);
if (xChange * yChange < 0) {
xChange = -xChange;
}
}
// Calculate our new sizes
const newWidth = originalBounds.width + xChange;
const newHeight = originalBounds.height + yChange;
// Apply the sizes and positioning to our ghost dummy
this.dragDummy.style.width = `${newWidth}px`;
if (this.handleDragStartInfo.handleX === 'left') {
this.dragDummy.style.left = `${-xChange}px`;
}
this.dragDummy.style.height = `${newHeight}px`;
if (this.handleDragStartInfo.handleY === 'top') {
this.dragDummy.style.top = `${-yChange}px`;
}
// Update corners and track dimension changes for later application
positionHandlesAtCorners(this.dragDummy, this.handles);
this.handleDragMoveDimensions = {
width: newWidth,
height: newHeight,
}
}
}
export default ImageView;

View file

@ -0,0 +1,21 @@
class TableView {
/**
* @param {PmNode} node
* @param {PmView} view
* @param {(function(): number)} getPos
*/
constructor(node, view, getPos) {
this.dom = document.createElement("div")
this.dom.className = "ProseMirror-tableWrapper"
this.table = this.dom.appendChild(document.createElement("table"));
this.table.setAttribute('style', node.attrs.style);
this.colgroup = this.table.appendChild(document.createElement("colgroup"));
this.contentDOM = this.table.appendChild(document.createElement("tbody"));
}
ignoreMutation(record) {
return record.type == "attributes" && (record.target == this.table || this.colgroup.contains(record.target))
}
}
export default TableView;

View file

@ -0,0 +1,11 @@
import ImageView from "./ImageView";
import IframeView from "./IframeView";
import TableView from "./TableView";
const views = {
image: (node, view, getPos) => new ImageView(node, view, getPos),
iframe: (node, view, getPos) => new IframeView(node, view, getPos),
table: (node, view, getPos) => new TableView(node, view, getPos),
};
export default views;

View file

@ -0,0 +1,58 @@
import crel from "crelt";
/**
* Render grab handles at the corners of the given element.
* @param {Element} elem
* @return {Element[]}
*/
export function renderHandlesAtCorners(elem) {
const handles = [];
const baseClass = 'ProseMirror-grabhandle';
for (let i = 0; i < 4; i++) {
const y = (i < 2) ? 'top' : 'bottom';
const x = (i === 0 || i === 3) ? 'left' : 'right';
const handle = crel('div', {
class: `${baseClass} ${baseClass}-${x}-${y}`,
});
handle.dataset.y = y;
handle.dataset.x = x;
handles.push(handle);
elem.parentNode.appendChild(handle);
}
positionHandlesAtCorners(elem, handles);
return handles;
}
/**
* @param {Element[]} handles
*/
export function removeHandles(handles) {
for (const handle of handles) {
handle.remove();
}
}
/**
*
* @param {Element} element
* @param {[Element, Element, Element, Element]}handles
*/
export function positionHandlesAtCorners(element, handles) {
const bounds = element.getBoundingClientRect();
const parentBounds = element.parentElement.getBoundingClientRect();
const positions = [
{x: bounds.left - parentBounds.left, y: bounds.top - parentBounds.top},
{x: bounds.right - parentBounds.left, y: bounds.top - parentBounds.top},
{x: bounds.right - parentBounds.left, y: bounds.bottom - parentBounds.top},
{x: bounds.left - parentBounds.left, y: bounds.bottom - parentBounds.top},
];
for (let i = 0; i < 4; i++) {
const {x, y} = positions[i];
const handle = handles[i];
handle.style.left = (x - 6) + 'px';
handle.style.top = (y - 6) + 'px';
}
}

View file

@ -0,0 +1,288 @@
/**
* This file originates from https://github.com/ProseMirror/prosemirror-tables
* and is hence subject to the MIT license found here:
* https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
* @copyright Marijn Haverbeke and others
*/
import {Plugin, PluginKey} from "prosemirror-state"
import {Decoration, DecorationSet} from "prosemirror-view"
import {
cellAround,
pointsAtCell,
setAttr,
TableMap,
} from "prosemirror-tables";
export const key = new PluginKey("tableColumnResizing")
export function columnResizing(options = {}) {
const {
handleWidth, cellMinWidth, lastColumnResizable
} = Object.assign({
handleWidth: 5,
cellMinWidth: 25,
lastColumnResizable: true
}, options);
let plugin = new Plugin({
key,
state: {
init(_, state) {
return new ResizeState(-1, false)
},
apply(tr, prev) {
return prev.apply(tr)
}
},
props: {
attributes(state) {
let pluginState = key.getState(state)
return pluginState.activeHandle > -1 ? {class: "resize-cursor"} : null
},
handleDOMEvents: {
mousemove(view, event) {
handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable)
},
mouseleave(view) {
handleMouseLeave(view)
},
mousedown(view, event) {
handleMouseDown(view, event, cellMinWidth)
}
},
decorations(state) {
let pluginState = key.getState(state)
if (pluginState.activeHandle > -1) return handleDecorations(state, pluginState.activeHandle)
},
nodeViews: {}
}
})
return plugin
}
class ResizeState {
constructor(activeHandle, dragging) {
this.activeHandle = activeHandle
this.dragging = dragging
}
apply(tr) {
let state = this, action = tr.getMeta(key)
if (action && action.setHandle != null)
return new ResizeState(action.setHandle, null)
if (action && action.setDragging !== undefined)
return new ResizeState(state.activeHandle, action.setDragging)
if (state.activeHandle > -1 && tr.docChanged) {
let handle = tr.mapping.map(state.activeHandle, -1)
if (!pointsAtCell(tr.doc.resolve(handle))) handle = null
state = new ResizeState(handle, state.dragging)
}
return state
}
}
function handleMouseMove(view, event, handleWidth, cellMinWidth, lastColumnResizable) {
let pluginState = key.getState(view.state)
if (!pluginState.dragging) {
let target = domCellAround(event.target), cell = -1
if (target) {
let {left, right} = target.getBoundingClientRect()
if (event.clientX - left <= handleWidth)
cell = edgeCell(view, event, "left")
else if (right - event.clientX <= handleWidth)
cell = edgeCell(view, event, "right")
}
if (cell != pluginState.activeHandle) {
if (!lastColumnResizable && cell !== -1) {
let $cell = view.state.doc.resolve(cell)
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
if (col == map.width - 1) {
return
}
}
updateHandle(view, cell)
}
}
}
function handleMouseLeave(view) {
let pluginState = key.getState(view.state)
if (pluginState.activeHandle > -1 && !pluginState.dragging) updateHandle(view, -1)
}
function handleMouseDown(view, event, cellMinWidth) {
let pluginState = key.getState(view.state)
if (pluginState.activeHandle == -1 || pluginState.dragging) return false
let cell = view.state.doc.nodeAt(pluginState.activeHandle)
let width = currentColWidth(view, pluginState.activeHandle, cell.attrs)
view.dispatch(view.state.tr.setMeta(key, {setDragging: {startX: event.clientX, startWidth: width}}))
function finish(event) {
window.removeEventListener("mouseup", finish)
window.removeEventListener("mousemove", move)
let pluginState = key.getState(view.state)
if (pluginState.dragging) {
updateColumnWidth(view, pluginState.activeHandle, draggedWidth(pluginState.dragging, event, cellMinWidth))
view.dispatch(view.state.tr.setMeta(key, {setDragging: null}))
}
}
function move(event) {
if (!event.which) return finish(event)
let pluginState = key.getState(view.state)
let dragged = draggedWidth(pluginState.dragging, event, cellMinWidth)
displayColumnWidth(view, pluginState.activeHandle, dragged, cellMinWidth)
}
window.addEventListener("mouseup", finish)
window.addEventListener("mousemove", move)
event.preventDefault()
return true
}
function currentColWidth(view, cellPos, {colspan, colwidth}) {
let width = colwidth && colwidth[colwidth.length - 1]
if (width) return width
let dom = view.domAtPos(cellPos)
let node = dom.node.childNodes[dom.offset]
let domWidth = node.offsetWidth, parts = colspan
if (colwidth) for (let i = 0; i < colspan; i++) if (colwidth[i]) {
domWidth -= colwidth[i]
parts--
}
return domWidth / parts
}
function domCellAround(target) {
while (target && target.nodeName != "TD" && target.nodeName != "TH")
target = target.classList.contains("ProseMirror") ? null : target.parentNode
return target
}
function edgeCell(view, event, side) {
let found = view.posAtCoords({left: event.clientX, top: event.clientY})
if (!found) return -1
let {pos} = found
let $cell = cellAround(view.state.doc.resolve(pos))
if (!$cell) return -1
if (side == "right") return $cell.pos
let map = TableMap.get($cell.node(-1)), start = $cell.start(-1)
let index = map.map.indexOf($cell.pos - start)
return index % map.width == 0 ? -1 : start + map.map[index - 1]
}
function draggedWidth(dragging, event, cellMinWidth) {
let offset = event.clientX - dragging.startX
return Math.max(cellMinWidth, dragging.startWidth + offset)
}
function updateHandle(view, value) {
view.dispatch(view.state.tr.setMeta(key, {setHandle: value}))
}
function updateColumnWidth(view, cell, width) {
let $cell = view.state.doc.resolve(cell);
let table = $cell.node(-1);
let map = TableMap.get(table);
let start = $cell.start(-1);
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1;
let tr = view.state.tr;
for (let row = 0; row < map.height; row++) {
let mapIndex = row * map.width + col;
// Rowspanning cell that has already been handled
if (row && map.map[mapIndex] == map.map[mapIndex - map.width]) continue
let pos = map.map[mapIndex]
let {attrs} = table.nodeAt(pos);
const newWidth = (attrs.colspan * width) + 'px';
tr.setNodeMarkup(start + pos, null, setAttr(attrs, "width", newWidth));
}
if (tr.docChanged) view.dispatch(tr)
}
function displayColumnWidth(view, cell, width, cellMinWidth) {
const $cell = view.state.doc.resolve(cell)
const table = $cell.node(-1);
const start = $cell.start(-1);
const col = TableMap.get(table).colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan - 1
let dom = view.domAtPos($cell.start(-1)).node
while (dom.nodeName !== "TABLE") {
dom = dom.parentNode
}
updateColumnsOnResize(view, table, dom, cellMinWidth, col, width)
}
function updateColumnsOnResize(view, tableNode, tableDom, cellMinWidth, overrideCol, overrideValue) {
console.log({tableNode, tableDom, cellMinWidth, overrideCol, overrideValue});
let totalWidth = 0;
let fixedWidth = true;
const rows = tableDom.querySelectorAll('tr');
for (let y = 0; y < rows.length; y++) {
const row = rows[y];
const cell = row.children[overrideCol];
cell.style.width = `${overrideValue}px`;
if (y === 0) {
for (let x = 0; x < row.children.length; x++) {
const cell = row.children[x];
if (cell.style.width) {
const width = Number(cell.style.width.replace('px', ''));
totalWidth += width || cellMinWidth;
} else {
fixedWidth = false;
totalWidth += cellMinWidth;
}
}
}
}
console.log(totalWidth);
if (fixedWidth) {
tableDom.style.width = totalWidth + "px"
tableDom.style.minWidth = ""
} else {
tableDom.style.width = ""
tableDom.style.minWidth = totalWidth + "px"
}
}
function zeroes(n) {
let result = []
for (let i = 0; i < n; i++) result.push(0)
return result
}
function handleDecorations(state, cell) {
let decorations = []
let $cell = state.doc.resolve(cell)
let table = $cell.node(-1), map = TableMap.get(table), start = $cell.start(-1)
let col = map.colCount($cell.pos - start) + $cell.nodeAfter.attrs.colspan
for (let row = 0; row < map.height; row++) {
let index = col + row * map.width - 1
// For positions that are have either a different cell or the end
// of the table to their right, and either the top of the table or
// a different cell above them, add a decoration
if ((col == map.width || map.map[index] != map.map[index + 1]) &&
(row == 0 || map.map[index - 1] != map.map[index - 1 - map.width])) {
let cellPos = map.map[index]
let pos = start + cellPos + table.nodeAt(cellPos).nodeSize - 1
let dom = document.createElement("div")
dom.className = "column-resize-handle"
decorations.push(Decoration.widget(pos, dom))
}
}
return DecorationSet.create(state.doc, decorations)
}

View file

@ -0,0 +1,131 @@
const link = {
attrs: {
href: {},
title: {default: null},
target: {default: null}
},
inclusive: false,
parseDOM: [{
tag: "a[href]", getAttrs: function getAttrs(dom) {
return {
href: dom.getAttribute("href"),
title: dom.getAttribute("title"),
target: dom.getAttribute("target"),
}
}
}],
toDOM: function toDOM(node) {
const ref = node.attrs;
const href = ref.href;
const title = ref.title;
const target = ref.target;
return ["a", {href, title, target}, 0]
}
};
const em = {
parseDOM: [{tag: "i"}, {tag: "em"}, {style: "font-style=italic"}],
toDOM() {
return ["em", 0]
}
};
const 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: function (node) {
return node.style.fontWeight != "normal" && null;
}
},
{
style: "font-weight", getAttrs: function (value) {
return /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null;
}
}],
toDOM() {
return ["strong", 0]
}
};
const code = {
parseDOM: [{tag: "code"}],
toDOM() {
return ["code", 0]
}
};
const underline = {
parseDOM: [{tag: "u"}, {style: "text-decoration=underline"}],
toDOM() {
return ["span", {style: "text-decoration: underline;"}, 0];
}
};
const strike = {
parseDOM: [{tag: "s"}, {tag: "strike"}, {style: "text-decoration=line-through"}],
toDOM() {
return ["span", {style: "text-decoration: line-through;"}, 0];
}
};
const superscript = {
parseDOM: [{tag: "sup"}],
toDOM() {
return ["sup", 0];
}
};
const subscript = {
parseDOM: [{tag: "sub"}],
toDOM() {
return ["sub", 0];
}
};
const text_color = {
attrs: {
color: {},
},
parseDOM: [{
style: 'color',
getAttrs(color) {
return {color}
}
}],
toDOM(node) {
return ['span', {style: `color: ${node.attrs.color};`}, 0];
}
};
const background_color = {
attrs: {
color: {},
},
parseDOM: [{
style: 'background-color',
getAttrs(color) {
return {color}
}
}],
toDOM(node) {
return ['span', {style: `background-color: ${node.attrs.color};`}, 0];
}
};
const marks = {
link,
em,
strong,
code,
underline,
strike,
superscript,
subscript,
text_color,
background_color,
};
export default marks;

View file

@ -0,0 +1,383 @@
import {orderedList, bulletList, listItem} from "prosemirror-schema-list";
import {Fragment} from "prosemirror-model";
/**
* @param {HTMLElement} node
* @return {string|null}
*/
function getAlignAttrFromDomNode(node) {
const classList = node.classList;
const styles = node.style || {};
const alignments = ['right', 'left', 'center', 'justify'];
for (const alignment of alignments) {
if (classList.contains('align-' + alignment) || styles.textAlign === alignment) {
return alignment;
}
}
return null;
}
/**
* @param node
* @param {Object} attrs
* @return {Object}
*/
function addAlignmentAttr(node, attrs) {
const positions = ['right', 'left', 'center', 'justify'];
for (const position of positions) {
if (node.attrs.align === position) {
return addClassToAttrs('align-' + position, attrs);
}
}
return attrs;
}
function getAttrsParserForAlignment(node) {
return {
align: getAlignAttrFromDomNode(node),
};
}
/**
* @param {String} className
* @param {Object} attrs
* @return {Object}
*/
function addClassToAttrs(className, attrs) {
return Object.assign({}, attrs, {
class: attrs.class ? attrs.class + ' ' + className : className,
});
}
/**
* @param {String[]} attrNames
* @return {function(Element): {}}
*/
function domAttrsToAttrsParser(attrNames) {
return function (node) {
const attrs = {};
for (const attr of attrNames) {
attrs[attr] = node.hasAttribute(attr) ? node.getAttribute(attr) : null;
}
return attrs;
};
}
/**
* @param {PmNode} node
* @param {String[]} attrNames
*/
function extractAttrsForDom(node, attrNames) {
const domAttrs = {};
for (const attr of attrNames) {
if (node.attrs[attr]) {
domAttrs[attr] = node.attrs[attr];
}
}
return domAttrs;
}
const doc = {
content: "block+",
};
const paragraph = {
content: "inline*",
group: "block",
parseDOM: [
{
tag: "p",
getAttrs: getAttrsParserForAlignment,
}
],
attrs: {
align: {
default: null,
}
},
toDOM(node) {
return ["p", addAlignmentAttr(node, {}), 0];
}
};
const blockquote = {
content: "block+",
group: "block",
defining: true,
parseDOM: [{tag: "blockquote", getAttrs: getAttrsParserForAlignment}],
attrs: {
align: {
default: null,
}
},
toDOM(node) {
return ["blockquote", addAlignmentAttr(node, {}), 0];
}
};
const horizontal_rule = {
group: "block",
parseDOM: [{tag: "hr"}],
toDOM() {
return ["hr"];
}
};
const headingParseGetAttrs = (level) => {
return function (node) {
return {level, align: getAlignAttrFromDomNode(node)};
};
};
const heading = {
attrs: {level: {default: 1}, align: {default: null}},
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{tag: "h1", getAttrs: headingParseGetAttrs(1)},
{tag: "h2", getAttrs: headingParseGetAttrs(2)},
{tag: "h3", getAttrs: headingParseGetAttrs(3)},
{tag: "h4", getAttrs: headingParseGetAttrs(4)},
{tag: "h5", getAttrs: headingParseGetAttrs(5)},
{tag: "h6", getAttrs: headingParseGetAttrs(6)},
],
toDOM(node) {
return ["h" + node.attrs.level, addAlignmentAttr(node, {}), 0]
}
};
const code_block = {
content: "text*",
marks: "",
group: "block",
code: true,
defining: true,
parseDOM: [{tag: "pre", preserveWhitespace: "full"}],
toDOM() {
return ["pre", ["code", 0]];
}
};
const text = {
group: "inline"
};
const image = {
inline: true,
attrs: {
src: {},
alt: {default: null},
title: {default: null},
height: {default: null},
width: {default: null},
},
group: "inline",
draggable: true,
parseDOM: [{
tag: "img[src]", getAttrs: function getAttrs(dom) {
return {
src: dom.getAttribute("src"),
title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"),
height: dom.getAttribute("height"),
width: dom.getAttribute("width"),
}
}
}],
toDOM: function toDOM(node) {
const ref = node.attrs;
const src = ref.src;
const alt = ref.alt;
const title = ref.title;
const width = ref.width;
const height = ref.height;
return ["img", {src, alt, title, width, height}]
}
};
const iframe = {
attrs: {
src: {},
height: {default: null},
width: {default: null},
title: {default: null},
allow: {default: null},
sandbox: {default: null},
},
group: "block",
draggable: true,
parseDOM: [{
tag: "iframe",
getAttrs: domAttrsToAttrsParser(["src", "width", "height", "title", "allow", "sandbox"]),
}],
toDOM(node) {
const attrs = extractAttrsForDom(node, ["src", "width", "height", "title", "allow", "sandbox"])
return ["iframe", attrs];
}
};
const hard_break = {
inline: true,
group: "inline",
selectable: false,
parseDOM: [{tag: "br"}],
toDOM() {
return ["br"];
}
};
const calloutParseGetAttrs = (type) => {
return function (node) {
return {type, align: getAlignAttrFromDomNode(node)};
};
};
const callout = {
attrs: {
type: {default: 'info'},
align: {default: null},
},
content: "inline*",
group: "block",
defining: true,
parseDOM: [
{tag: 'p.callout.info', getAttrs: calloutParseGetAttrs('info'), priority: 75},
{tag: 'p.callout.success', getAttrs: calloutParseGetAttrs('success'), priority: 75},
{tag: 'p.callout.danger', getAttrs: calloutParseGetAttrs('danger'), priority: 75},
{tag: 'p.callout.warning', getAttrs: calloutParseGetAttrs('warning'), priority: 75},
{tag: 'p.callout', getAttrs: calloutParseGetAttrs('info'), priority: 75},
],
toDOM(node) {
const type = node.attrs.type || 'info';
return ['p', addAlignmentAttr(node, {class: 'callout ' + type}), 0];
}
};
const ordered_list = Object.assign({}, orderedList, {content: "list_item+", group: "block"});
const bullet_list = Object.assign({}, bulletList, {content: "list_item+", group: "block"});
const list_item = Object.assign({}, listItem, {content: 'paragraph block*'});
const table = {
content: "table_row+",
attrs: {
style: {default: null},
},
tableRole: "table",
isolating: true,
group: "block",
parseDOM: [{tag: "table", getAttrs: domAttrsToAttrsParser(['style'])}],
toDOM(node) {
return ["table", extractAttrsForDom(node, ['style']), ["tbody", 0]]
}
};
const table_row = {
content: "(table_cell | table_header)*",
tableRole: "row",
parseDOM: [{tag: "tr"}],
toDOM() { return ["tr", 0] }
};
let cellAttrs = {
colspan: {default: 1},
rowspan: {default: 1},
width: {default: null},
height: {default: null},
};
function getCellAttrs(dom) {
return {
colspan: Number(dom.getAttribute("colspan") || 1),
rowspan: Number(dom.getAttribute("rowspan") || 1),
width: dom.style.width || null,
height: dom.style.height || null,
};
}
function setCellAttrs(node) {
let attrs = {};
const styles = [];
if (node.attrs.colspan != 1) attrs.colspan = node.attrs.colspan;
if (node.attrs.rowspan != 1) attrs.rowspan = node.attrs.rowspan;
if (node.attrs.width) styles.push(`width: ${node.attrs.width}`);
if (node.attrs.height) styles.push(`height: ${node.attrs.height}`);
if (styles) {
attrs.style = styles.join(';');
}
return attrs
}
const table_cell = {
content: "block+",
attrs: cellAttrs,
tableRole: "cell",
isolating: true,
parseDOM: [{tag: "td", getAttrs: dom => getCellAttrs(dom)}],
toDOM(node) { return ["td", setCellAttrs(node), 0] }
};
const table_header = {
content: "block+",
attrs: cellAttrs,
tableRole: "header_cell",
isolating: true,
parseDOM: [{tag: "th", getAttrs: dom => getCellAttrs(dom)}],
toDOM(node) { return ["th", setCellAttrs(node), 0] }
};
const details = {
content: "details_summary block*",
isolating: true,
group: "block",
parseDOM: [{
tag: "details",
getAttrs(domNode) {
return {}
},
}],
toDOM(node) {
return ["details", 0];
}
};
const details_summary = {
content: "inline*",
group: "block",
parseDOM: [{
tag: "details summary",
}],
toDOM(node) {
return ["summary", 0];
}
};
const nodes = {
doc,
paragraph,
blockquote,
horizontal_rule,
heading,
code_block,
text,
image,
iframe,
hard_break,
callout,
ordered_list,
bullet_list,
list_item,
table,
table_row,
table_cell,
table_header,
details,
details_summary,
};
export default nodes;

View file

@ -0,0 +1,12 @@
import {Schema} from "prosemirror-model";
import nodes from "./schema-nodes";
import marks from "./schema-marks";
/** @var {PmSchema} schema */
const schema = new Schema({
nodes,
marks,
});
export default schema;

View file

@ -0,0 +1,106 @@
/**
* @typedef {Object} PmEditorState
* @property {PmNode} doc
* @property {PmSelection} selection
* @property {PmMark[]|null} storedMarks
* @property {PmSchema} schema
* @property {PmTransaction} tr
*/
/**
* @typedef {Object} PmNode
* @property {PmNodeType} type
* @property {Object} attrs
* @property {PmFragment} content
* @property {PmMark[]} marks
* @property {String|null} text
* @property {Number} nodeSize
* @property {Number} childCount
*/
/**
* @typedef {Object} PmNodeType
*/
/**
* @typedef {Object} PmMark
* @property {PmMarkType} type
* @property {Object} attrs
*/
/**
* @typedef {Object} PmMarkType
* @property {String} name
* @property {PmSchema} schema
* @property {PmMarkSpec} spec
*/
/**
* @typedef {Object} PmMarkSpec
*/
/**
* @typedef {Object} PmSchema
* @property {PmSchema} schema
* @property {Object<PmNodeType>} nodes
* @property {Object<PmMarkType>} marks
* @property {PmNodeType} topNodeType
* @property {Object} cached
*/
/**
* @typedef {Object} PmSelection
* @property {PmSelectionRange[]} ranges
* @property {PmResolvedPos} $anchor
* @property {PmResolvedPos} $head
* @property {Number} anchor
* @property {Number} head
* @property {Number} from
* @property {Number} to
* @property {PmResolvedPos} $from
* @property {PmResolvedPos} $to
* @property {Boolean} empty
*/
/**
* @typedef {Object} PmResolvedPos
* @property {Number} pos
* @property {Number} depth
* @property {Number} parentOffset
* @property {PmNode} parent
* @property {PmNode} doc
*/
/**
* @typedef {Object} PmSelectionRange
*/
/**
* @typedef {Object} PmTransaction
* @property {Number} time
* @property {PmMark[]|null} storedMarks
* @property {PmSelection} selection
*/
/**
* @typedef {Object} PmFragment
*/
/**
* @typedef {Function} PmCommandHandler
* @param {PmEditorState} state
* @param {PmDispatchFunction} dispatch
*/
/**
* @typedef {Function} PmDispatchFunction
* @param {PmTransaction} tr
*/
/**
* @typedef {Object} PmView
* @param {PmEditorState} state
* @param {Element} dom
* @param {Boolean} editable
* @param {Boolean} composing
*/

131
resources/js/editor/util.js Normal file
View file

@ -0,0 +1,131 @@
import schema from "./schema";
import {DOMParser, DOMSerializer} from "prosemirror-model";
/**
* @param {String} html
* @return {PmNode}
*/
export function htmlToDoc(html) {
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.innerHTML = html;
return DOMParser.fromSchema(schema).parse(renderDoc.body);
}
/**
* @param {PmNode} doc
* @return {string}
*/
export function docToHtml(doc) {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(doc.content);
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.appendChild(fragment);
return renderDoc.body.innerHTML;
}
/**
* @param {PmEditorState} state
* @return {String}
*/
export function stateToHtml(state) {
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(state.doc.content);
const renderDoc = document.implementation.createHTMLDocument();
renderDoc.body.appendChild(fragment);
return renderDoc.body.innerHTML;
}
/**
* @param {Object} object
* @return {{}}
*/
export function nullifyEmptyValues(object) {
const clean = {};
for (const [key, value] of Object.entries(object)) {
clean[key] = (value === "") ? null : value;
}
return clean;
}
/**
* @param {PmEditorState} state
* @param {PmSelection} selection
* @param {PmMarkType} markType
* @return {{from: Number, to: Number}}
*/
export function expandSelectionToMark(state, selection, markType) {
let {from, to} = selection;
const noRange = (from === to);
if (noRange) {
const markRange = markRangeAtPosition(state, markType, from);
if (markRange.from !== -1) {
from = markRange.from;
to = markRange.to;
}
}
return {from, to};
}
/**
* @param {PmEditorState} state
* @param {PmMarkType} markType
* @param {Number} pos
* @return {{from: Number, to: Number}}
*/
export function markRangeAtPosition(state, markType, pos) {
const $pos = state.doc.resolve(pos);
const {parent, parentOffset} = $pos;
const start = parent.childAfter(parentOffset);
if (!start.node) return {from: -1, to: -1};
const mark = start.node.marks.find((mark) => mark.type === markType);
if (!mark) return {from: -1, to: -1};
let startIndex = $pos.index();
let startPos = $pos.start() + start.offset;
let endIndex = startIndex + 1;
let endPos = startPos + start.node.nodeSize;
while (startIndex > 0 && mark.isInSet(parent.child(startIndex - 1).marks)) {
startIndex -= 1;
startPos -= parent.child(startIndex).nodeSize;
}
while (endIndex < parent.childCount && mark.isInSet(parent.child(endIndex).marks)) {
endPos += parent.child(endIndex).nodeSize;
endIndex += 1;
}
return {from: startPos, to: endPos};
}
/**
* @class KeyedMultiStack
* Holds many stacks, seperated via a key, with a simple
* interface to pop and push values to the stacks.
*/
export class KeyedMultiStack {
constructor() {
this.stack = {};
}
/**
* @param {String} key
* @return {undefined|*}
*/
pop(key) {
if (Array.isArray(this.stack[key])) {
return this.stack[key].pop();
}
return undefined;
}
/**
* @param {String} key
* @param {*} value
*/
push(key, value) {
if (this.stack[key] === undefined) {
this.stack[key] = [];
}
this.stack[key].push(value);
}
}

607
resources/sass/_editor.scss Normal file
View file

@ -0,0 +1,607 @@
#editor.bs-editor {
padding-top: 0;
}
//.bs-editor .menubar {
// border-bottom: 1px solid #DDD;
// padding: 2px;
//}
//
//.bs-editor .menuicon {
// cursor: pointer;
// padding: 4px;
// min-width: 2rem;
// border-radius: 3px;
// border: 1px solid transparent;
// &:hover {
// background-color: #EEE;
// border: 1px solid #DDD;
// }
//}
// The below originated from https://github.com/ProseMirror/prosemirror-menu
// and is therefore subject to the MIT license found here:
// https://github.com/ProseMirror/prosemirror-menu/blob/master/LICENSE
.ProseMirror {
position: relative;
}
.ProseMirror {
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror table td, .ProseMirror table th {
min-height: 1rem;
}
.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;
}
/* Protect against generic img rules */
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}
.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 {
padding-inline-end: 12px;
padding-inline-start: 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: 10em;
left: 100%;
top: -3px;
}
.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 {
border-top: 3px solid #FFF;
border-bottom: 3px solid #FFF;
box-sizing: content-box;
}
.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
}
.ProseMirror-menu-color-grid-container {
display: grid;
grid-template-columns: repeat(8, 1fr);
}
.ProseMirror-menu-color-grid-item {
width: 20px;
height: 20px;
border: 2px solid #FFF;
display: block;
}
.ProseMirror-menu-table-creator-grid {
display: grid;
gap: 2px;
}
.ProseMirror-menu-table-creator-grid-item {
width: 14px;
height: 14px;
border: 2px solid #BBB;
display: block;
cursor: pointer;
}
.ProseMirror-menu-table-creator-grid-item-active {
border: 2px solid #555;
background-color: #DDD;
}
.ProseMirror-menu-table-creator-grid-label {
padding: $-xs;
text-align: center;
}
.ProseMirror-menu-dialog-wrap {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.1);
z-index: 50;
display: grid;
}
.ProseMirror-menu-dialog-title {
padding: $-xs $-s;
border-bottom: 1px solid #DDD;
font-weight: bold;
position: relative;
margin-bottom: $-xs;
}
.ProseMirror-menu-dialog-footer {
padding: $-xs $-s;
border-top: 1px solid #DDD;
display: flex;
justify-content: end;
margin-top: $-xs;
}
.ProseMirror-menu-dialog-title-close {
color: #FFF;
position: absolute;
top: $-xs + 2px;
right: $-s;
border-radius: 9px;
height: 18px;
width: 18px;
text-align: center;
line-height: 0;
vertical-align: top;
display: flex;
justify-content: center;
align-items: center;
}
.ProseMirror-menu-dialog {
background-color: #FFF;
border: 1px solid #DDD;
border-radius: 3px;
box-shadow: $bs-large;
width: fit-content;
min-width: 300px;
min-height: 100px;
margin: auto;
}
.ProseMirror-menu-dialog-button {
border: 1px solid #DDD;
padding: $-xs $-s;
color: #666;
min-width: 80px;
cursor: pointer;
&:hover {
background-color: #EEE;
}
}
.ProseMirror-menu-dialog-button + .ProseMirror-menu-dialog-button {
margin-left: $-xs;
}
.ProseMirror-menu-dialog-form-row {
display: grid;
grid-template-columns: 1fr 2fr;
align-items: center;
padding: $-xs 0;
label {
padding: 0 $-s;
font-size: .9rem;
}
input {
margin: 0 $-s;
}
}
.ProseMirror-menu-dialog-textarea-wrap {
padding: $-xs $-s;
label {
padding: 0 $-s;
font-size: .9rem;
}
textarea {
width: 100%;
font-size: 0.8rem;
}
}
.ProseMirror-imagewrap, .ProseMirror-iframewrap {
display: inline-block;
line-height: 0;
font-size: 0;
position: relative;
}
.ProseMirror-imagewrap.ProseMirror-selectednode {
outline: 0;
}
.ProseMirror img[data-show-handles] {
outline: 4px solid #000;
}
.ProseMirror .ProseMirror-iframewrap iframe {
pointer-events: none !important;
}
.ProseMirror-dragdummy {
position: absolute;
z-index: 2;
left: 0;
top: 0;
max-width: none !important;
max-height: none !important;
}
.ProseMirror-grabhandle {
width: 12px;
height: 12px;
border: 2px solid #000;
z-index: 4;
position: absolute;
background-color: #FFF;
}
.ProseMirror-grabhandle-left-top {
cursor: nw-resize;
}
.ProseMirror-grabhandle-right-top {
cursor: ne-resize;
}
.ProseMirror-grabhandle-right-bottom {
cursor: se-resize;
}
.ProseMirror-grabhandle-left-bottom {
cursor: sw-resize;
}
.ProseMirror .tableWrapper {
overflow-x: auto;
}
.ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.ProseMirror td, .ProseMirror th {
vertical-align: top;
box-sizing: border-box;
position: relative;
}
.ProseMirror .column-resize-handle {
position: absolute;
right: -2px; top: 0; bottom: 0;
width: 4px;
z-index: 20;
background-color: #adf;
pointer-events: none;
}
.ProseMirror.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
/* Give selected cells a blue overlay */
.ProseMirror .selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0; right: 0; top: 0; bottom: 0;
background: rgba(200, 200, 255, 0.4);
pointer-events: none;
}

View file

@ -105,6 +105,9 @@ body.mce-fullscreen, body.markdown-fullscreen {
margin-left: auto;
margin-right: auto;
}
.align-justify {
text-align: justify;
}
img {
max-width: 100%;
height:auto;

View file

@ -20,6 +20,7 @@
@import "footer";
@import "lists";
@import "pages";
@import "editor";
// Jquery Sortable Styles
.dragged {

View file

@ -0,0 +1,83 @@
@extends('layouts.simple')
@section('body')
<div class="container">
<div>
<input id="markdown-toggle" type="checkbox">
</div>
<div id="editor" class="bs-editor page-content" style="margin-bottom: 23px"></div>
<div id="content" style="display: none;">
<h2>This is an editable block</h2>
<p>
Lorem ipsum dolor sit amet, <strong>consectetur adipisicing</strong> elit. Asperiores? <br>
Some <span style="text-decoration: underline">Underlined content</span> Lorem ipsum dolor sit amet. <br>
Some <span style="text-decoration: line-through;">striked content</span> Lorem ipsum dolor sit amet. <br>
Some <span style="color: red;">Red Content</span> Lorem ipsum dolor sit amet. <br>
Some <a href="https://cats.com" target="_blank" title="link A">Linked Content</a> Lorem ipsum dolor sit amet. <br>
</p>
<details>
<summary>Dropdown here</summary>
<h1>Inner header</h1>
<p>Paragraph</p>
</details>
<table style="width: 100%;">
<thead>
<tr>
<th>Header A</th>
<th>Header B</th>
</tr>
</thead>
<tbody>
<tr>
<td style="width: 250px; height: 30px">Content 1</td>
<td style="width: 320px; height: 30px">Content 2</td>
<td style="width: 320px; height: 30px">Content 2</td>
</tr>
<tr>
<td colspan="2">Row 2, Spanning 2</td>
<td>Row 2 spanning 1</td>
</tr>
<tr>
<td rowspan="2">Row 3/4, Column 1</td>
<td>Row 3, Column 2</td>
<td>Row 3, Column 3</td>
</tr>
<tr>
<td>Row 4, Column 2</td>
<td>Row 4, Column 3</td>
</tr>
</tbody>
</table>
{{-- <iframe width="560" height="315" src="https://www.youtube.com/embed/n6hIa-fPx0M" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>--}}
<p><img src="/user_avatar.png" alt="Logo"></p>
<ul>
<li>Item A</li>
<li>Item B</li>
<li>Item C</li>
</ul>
<p>Lorem ipsum dolor sit amet.</p>
<hr>
<p class="align-right">Lorem ipsum dolor sit amet.</p>
<p class="callout info">
This is an info callout test!
</p>
</div>
</div>
@endsection
@section('scripts')
<script src="{{ versioned_asset('dist/editor.js') }}" nonce="{{ $cspNonce }}"></script>
@stop

View file

@ -38,6 +38,8 @@ use Illuminate\View\Middleware\ShareErrorsFromSession;
Route::get('/status', [StatusController::class, 'show']);
Route::get('/robots.txt', [HomeController::class, 'robots']);
Route::view('/editor-test', 'editor-test');
// Authenticated routes...
Route::middleware('auth')->group(function () {