slab / quill

Quill is a modern WYSIWYG editor built for compatibility and extensibility
https://quilljs.com
BSD 3-Clause "New" or "Revised" License
43.17k stars 3.35k forks source link

Feature Request : Mutli-line block formats #1121

Open RobAley opened 7 years ago

RobAley commented 7 years ago

1. A detailed description of the feature

Currently, if you press enter when any type of block fomat (standard paragraph, blockquote etc.) a new html node of the relevant type is created. For instance, if I have a blockquote which quotes two paragraphs from another source, I will end up with an HTML representation like :

<blockquote>This is the first paragraph, and now I press enter</blockquote>
<blockquote>This is the second paragraph</blockquote>

If I wish to style the blockquote above with CSS, I cannot do it as one block easily, for instance putting quote marks in CSS :before and :after content will do it twice, or adding borders will put a border around each individual blockquote element. Using more complicated CSS selectors (such as P+BLOCKQUOTE to target the first one) runs into problems when trying to target the last one, e.g. to put a bottom border on it or a closing quote mark, as CSS selectors can't easily look backwards.

What I would like to happen is that consecutive blocks of the same type are merged into one with a
or similar, so the above html would become something like :

<blockquote>This is the first paragraph, and now I press enter<br>This is the second paragraph</blockquote>

This could be by default for all block types, or selectable for custom types only or similar. An alternative to achieve the same would be some form of "container" block that allows other block formats within it, e.g.


<div class="container"><p>This is the first paragraph, and now I press enter</p>
<p>This is the second paragraph</p></div>

I.e. so you almost have another quill document within the container. However that's probably a lot more drastic in terms of code changes.

2. Why this feature belongs in Quill core, instead of your own application logic

I would be happy to put in my application logic, but haven't found a way to. I think it fits more naturally as a behaviour of the core as its a structural thing rather than an add-on type feature.

3. Background of where and how you are using Quill

We are wanting to use quill to develop an in-browser wysiwyg e-book editor.

4. The use case that would be enabled or improved for your product, if the feature was implemented

The use case for this feature would be in creating custom "aside" and "exercise" blocks (and multi-paragraph blockquotes as above) which can be styled as a unit. Asides (and warning/information box-outs etc) in books often have line-breaks/paragraphs and similar, and common styling includes all-round borders or top & bottom lines (borders) to pick them out of the main text flow.

nozer commented 7 years ago

I created a quill delta to html converter and it allows this. You can check it out at https://github.com/nozer/quill-delta-to-html

seriatechnologies-mateen commented 6 years ago

Is there any update on the requested feature. @RobAley Do you have any workaround for this problem ?

charrondev commented 6 years ago

I've created some custom blots to make this work, and it definitely is possible. I'll be sure to share what I've put together here once I wrap my PR over at vanilla/vanilla, but we have some Blot's nested 3 levels deep with additional Embed Blots attached inside.

As an example our Spoiler ends up with the following DOM structure:

<div class="spoiler">
  <button class="spoiler-toggleButton" >
    <!-- Opens/Collapsed the content. Lots of stuff in here-->
    ...
    </button> 
  <div class="spoiler-content">
    <p>One line of the spoiler</p>
    <p>Another line of the spoiler</p>
  </div>
</div>

You basically get it wiring together a few Container Blots. I've been working to try to make the Blots a bit more re-usable at the moment because we have a similar structure for quotes as well, and have containers around a couple of our other Blot's for backwards compatibility with a bunch of legacy stylesheets.

RobAley commented 6 years ago

Thanks, it will be great to see your code, it's something I'm still struggling with.

On Wed, 28 Feb 2018, at 6:23 AM, Adam Charron wrote:

I've created some custom blots to make this work, and it definitely is possible. I'll be sure to share what I've put together here once I wrap my PR over at vanilla/vanilla, but we have some Blot's nested 3 levels deep with additional Embed Blots attached inside.> As an example our Spoiler ends up with the following DOM structure:>

One line of the spoiler

Another line of the spoiler

> You basically get it wiring together a few Container Blots. I've been working to try to make the Blots a bit more re-usable at the moment because we have a similar structure for quotes as well, and have containers around a couple of our other Blot's for backwards compatibility with a bunch of legacy stylesheets.> — You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub[1], or mute the thread[2].>

Links:

  1. https://github.com/quilljs/quill/issues/1121#issuecomment-369134862
  2. https://github.com/notifications/unsubscribe-auth/AFj7gPO69Oz4wWxlpCAxv-KaaQhtXxBUks5tZPDVgaJpZM4K3pWd
bcipriano commented 5 years ago

I stumbled upon this FR while try to do a few very similar things. Here's what I came up with.

@charrondev 's code and his blog post were a HUGE help figuring out what was going on here. Thanks!!

I don't know if it matters, but I'm using Quill through activeadmin_quill_editor.

My goal was to add two new elements to my Quill editor:

  1. A "pullquote" element, basically an <aside> full of <p>s.
  2. A link button, just an <a> with a <button> inside.

There may be some simpler way to do (2) that I'm not aware of, but it made a nice second use case for WrapperBlot/WrappedBlot.

It's definitely not perfect -- undo and copy-paste functionality are both still weird, for example -- but it seems to work well enough for now.

const Parchment = Quill.import('parchment');
const Block = Quill.import('blots/block');
const Container = Quill.import('blots/container');

function initEditors() {
    initPullquote();
    initButtonLink();

    let default_options = {
        formats: [
            'align',
            'blockquote',
            'bold',
            'button',
            'button-link',
            'code',
            'code-block',
            'direction',
            'header',
            'image',
            'indent',
            'italic',
            'link',
            'list',
            'pullquote',
            'pullquote-para',
            'strike',
            'script',
            'underline',
            'video'
        ],
        modules: {
            clipboard: {
                matchVisual: false  // https://quilljs.com/docs/modules/clipboard/#matchvisual
            },
            toolbar: {
                container: [
                    ['bold', 'italic', 'strike'],
                    [{ 'header': 2 }],
                    [{ 'list': 'ordered'}, { 'list': 'bullet' }],
                    [{ align: '' }, { align: 'center' }, { align: 'right' }, { 'script': 'super' }],
                    ['blockquote', 'pullquote'],
                    ['link', 'button-link', 'image', 'video']
                ],
                handlers: {
                    'button-link': function (value) {
                        const href = prompt('Enter the URL');
                        if (value) {
                            this.quill.format('button-link', href);
                        } else {
                            this.quill.format('button-link', false);
                        }
                    }
                }
            }
        },
        theme: 'snow'
    };
}

class WrapperBlot extends Container {
    static scope = Parchment.Scope.BLOCK_BLOT;

    optimize(context) {
        super.optimize(context);
        let next = this.next;
        if (
            next != null &&
            next.prev === this &&
            next.statics.blotName === this.statics.blotName &&
            next.domNode.tagName === this.domNode.tagName
        ) {
            next.moveChildren(this);
            next.remove();
        }
    }

    replace(target) {
        if (target.statics.blotName !== this.statics.blotName) {
            let item = Parchment.create(this.statics.defaultChild);
            target.moveChildren(item);
            this.appendChild(item);
        }
        super.replace(target);
    }
}

class WrappedBlot extends Block {
    static deleteWhenEmpty = false;

    attach() {
        super.attach();
        const possibleParentNames = Array.isArray(this.statics.parentName)
            ? this.statics.parentName
            : [this.statics.parentName];
        if (!possibleParentNames.includes(this.parent.statics.blotName)) {
            const wrapper = Parchment.create(this.statics.parentName);
            this.wrap(wrapper);
        }
    }

    static formats(domNode) {
        const classMatch = this.className && domNode.classList.contains(this.className);
        const tagMatch = domNode.tagName.toLowerCase() === this.tagName.toLowerCase();

        return this.className ? classMatch && tagMatch : tagMatch;
    }

    remove() {
        if (this.prev == null && this.next == null) {
            this.parent.remove();
        } else {
            super.remove();
        }
    }

    removeChild(child) {
        super.removeChild(child);
        if (this.statics.deleteWhenEmpty && child.prev == null && child.next == null) {
            this.remove();
        }
    }

    replaceWith(name, value) {
        this.parent.isolate(this.offset(this.parent), this.length());
        if (name === this.parent.statics.blotName) {
            this.parent.replaceWith(name, value);
            return this;
        } else {
            this.parent.unwrap();
            super.replaceWith(name, value);
        }
    }
}

function initPullquote() {
    let icons = Quill.import('ui/icons');

    icons['pullquote'] = '<div class="icon-pullquote" aria-hidden="true"></div>';

    class PullquoteContainer extends WrapperBlot {
        static blotName = 'pullquote';
        static tagName = 'aside';
        static defaultChild = 'pullquote-para';
    }

    class PullquotePara extends WrappedBlot {
        static blotName = 'pullquote-para';
        static className = 'pullquote-para';
        static tagName = 'p';
        static parentName = 'pullquote';
    }

    PullquoteContainer.allowedChildren = [PullquotePara];
    PullquotePara.requiredContainer = PullquoteContainer;

    Quill.register(PullquoteContainer);
    Quill.register(PullquotePara);
}

function initButtonLink() {
    let icons = Quill.import('ui/icons');

    icons['button-link'] = '<i class="fa fa-share-square" style="color:black;" aria-hidden="true"></i>';

    class ButtonLink extends WrapperBlot {
        static blotName = 'button-link';
        static tagName = 'a';
        static className = 'button-link';
        static defaultChild = 'button';

        static formats(domNode) {
            return domNode.getAttribute('href');
        }

        static create(value) {
            const node = super.create(value);
            node.setAttribute('href', value);
            return node;
        }
    }

    class Button extends WrappedBlot {
        static blotName = 'button';
        static className = 'big-button';
        static tagName = 'button';
        static parentName = 'button-link';
        static deleteWhenEmpty = true;
    }

    ButtonLink.allowedChildren = [Button];
    Button.requiredContainer = ButtonLink;

    Quill.register(ButtonLink);
    Quill.register(Button);
}

$(document).ready(initEditors);