wyg
v0.0.30
Published
A new WYSIWYG editing experience for the modern web.
Downloads
17
Maintainers
Readme
Wyg Editor
ATTENTION: WORK IN PROGRESS. NOT MEANT TO BE USED BY ANYONE — STILL IN DEVELOPMENT PHASE, MANY BUGS TO FIX
A new WYSIWYG editing experience for the modern web.
- Built entirely from scratch
- Works better than anything before
- Minimalistic UI, blazingly fast (works with DOM nodes directly)
- Normalizes entered markup on the fly (splits paragraphs with linebreaks)
- Smart linebreak management (no Shift-Enter required to put a newline)
- Drag & drop media between paragraphs
- Insert media by simply pasting links into text
- Arranges media nicely in columns
- Fluid layout with animations
- Floating markup panel with custom tags support
- Serializes to/from JSON
- Custom undo/redo manager (works with arbitrary DOM changes)
- Intercepts native undo/redo commands (not limited to hotkeys)
- Pluggable/extendable architecture based on traits
- Test-driven
Recent news
- Implemented ability to insert text before/after media blocks: see demo video
- Implemented "choose file" button behavior
- Added doc entry on how to set and interpret the
value
property. - Refactored the file uploading API.
- Fixed crash when media links get pasted into empty editor.
- Implemented pluggable URL parsers and media renderers facility.
TODO
In it's current state the stuff is quite unusable. Things to be done:
- [ ] Safari compatibility (some tests fail).
- [ ] MS Edge compatibility.
- [ ] Implement hyperlink editing in the floating markup panel.
- [x] Fix nonworking plus button (should trigger file open dialog).
- [ ] Add build script.
Minor (but important) tasks:
- [x] Implement ability to insert text paragraphs at arbitrary location around media rows (see demo video).
- [ ] Get rid of jQuery (moving to the
Node+
library) - [ ] Make use of
$depends
mechanism for traits.
Running demo
You will need node
and npm
.
- Clone repo with
git clone http://github.com/xpl/wyg
- Run
npm install
to install dependencies. - Run
node demo.js
- Open
localhost:1333
in Chrome (Safari / Firefox / Edge support is coming)
Updating demo
Instead of git pull
, use ./update.sh
(runs git pull && npm update
). This is needed because it's dependencies change rapidly and you need to update all shit together to maintain consistency.
Under the hood
Everything is built upon a JS library called Useless.js (working title). It delivers composable traits support to JavaScript and a powerful unit test system. You may read more about it in the project's wiki. DOM operations are based on the Node+ library (coming with Useless).
Setting value
Editor's state is exposed via the value
property:
wyg.value = [
{ type: 'p', html: 'this is <b>text paragraph</b>, containing arbitrary HTML' },
{ type: 'media', // media row
media: [
{ type: 'img', // media element
src: 'http://example/some-image.jpg',
originalSize: { width: 1280, height: 720 } },
{ type: 'iframe', // media element
src: '...'
originalSize: ... }
]
},
...
]
Interpreting value
When reading value
, some additional metadata is returned on media elements:
{ type: 'img',
src: '...'
originalSize: ...
relativeSize: {
width: 0.5, // relative to page width
height: 0.247 // relative to element width
}
}
Relative size encodes the calculated size of a media item, relative to page width. It is abstract from absolute metrics and screen sizes, so a responsive layout could be generated from that schema.
Here's how you can describe an element which height is encoded as a percentage of its width, with pure CSS:
<media-row>
<media-item style="width: 50%;">
<spacer style="padding-top: 24.7%;"></spacer>
<content style="background-image:url(some-image.jpg);"></content>
</media-item>
...
</media-row>
media-row { display: block; white-space: nowrap; overflow: hidden; }
media-item { display: inline-block; position: relative; overflow: hidden; }
media-item spacer { display: block; background-size: cover; }
media-item content { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
It works because padding percentages are relative to element's width, even with padding-top
. So a height can be encoded via an additional spacer
element put inside.
Implementing the file uploading
See the reference implementation at the file_uploading.js
trait. You will need to implement the uploadFile
method.
With a Promise, it should return elements instantiated by the renderMedia
factory:
uploadFile: function (file, then) {
return JSONAPI.uploadFile ('/uploads', file).then (this.$ (function (response) {
return this.renderMedia ({ type: 'img',
src: '/uploads/' + response.file + '.jpg',
originalSize: { width: response.w,
height: response.h } }) })).panic },
See instructions below on how to extend the renderMedia
method behavior.
Adding support of new media types
All incoming URLs that are pasted from clipboard go through parseMedia
facility. This function converts URLs to abstract media definitions in JSON format. Those definitions, when serialized, can be easily stored/interpreted by external applications (e.g. template engines, when rendering to static HTML at server side).
Example:
this.parseMedia ('https://www.youtube.com/watch?v=JQ0qgyCuoCw')
.then (x => console.log (x))
Rendered output will be:
{ type: 'iframe',
src: '...',
originalUrl: 'https://www.youtube.com/watch?v=JQ0qgyCuoCw',
originalSize: { width: ..., height: ... } }
This is then feeded to the media rendering facility, which processes those definitions, producing DOM elements:
this.renderMedia (def) // produces DOM element from that definition
Both functions can be extended to introduce new behavior. This is how you do that.
1. Extending parseMedia
Tag a method with $parseMedia to designate it as an URL parser. For asynchronous parsing, you can return Promise:
images: $parseMedia (function (url) {
return Image.fetch (url)
.then (function (img) {
return { type: 'img',
src: url,
originalSize: { width: img.width,
height: img.height } } }) }),
youtube: $parseMedia (function (url) {
var match = url.match(/^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/)
var id = match && (match[7].length == 11) && match[7]
if (id) {
return {
type: 'iframe',
src: '//www.youtube.com/embed/' + id + '?wmode=transparent',
originalSize: { width: 16000, // numbers here encode aspect ratio + max size (not actual onscreen size)
height: 9000 } } } }),
You can also use tag groups (a feature of every $prototype) to reduce clutter in case of many parser methods:
$parseMedia: {
images: ...
youtube: ...
vimeo: ...
soundcloud: ... }
You can query what types are supported by checking static supportedMedia
property. It will be generated from method names:
MyEditor.supportedMedia // ['images', 'youtube', ...]
For those who are curious, here's the actual implementation of the parseMedia
factory:
parseMedia: function (url) {
/* Gather values from all methods tagged with $parseMedia */
var values = _.map (this.constructor.$membersByTag.parseMedia,
function (def, name) {
return this[name] (url) || Promise.reject (null) }, this)
return Promise.firstResolved (values)
.then (function (media) {
return _.extend (media, { originalUrl: url }) }) },
2. Extending renderMedia
This is done much the same way as with the former one:
img: $renderMedia (function (media) {
return N.img.attr ({ src: media.src,
width: media.originalSize.width,
height: media.originalSize.height }) }),
iframe: $renderMedia (function (media) {
return N.div.add (
N.iframe.attr ({ src: media.src, frameborder: 0, allowfullscreen: true })) } }),
Those methods are dispatched by looking into the type
property in media definitions.
Changing default icons
Default icons are hard-coded as SVG HTML, and you can change them them by overriding these methods:
makeWaitIcon: function () {
return N.div.extend ({ className: 'wyg-icon', innerHTML: '<svg>...' }) },
makeAddIcon: function () {
return N.div.extend ({ className: 'wyg-icon', innerHTML: '<svg>...' }) },
Expected return value is a DOM node. For example, returning an FontAwesome icon (some CSS tweaks may be required):
make: function () {
return N.div.cls ('wyg-icon fa fa-plus-square') },