slab / quill

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

Changing Link Title rather than just URL #2277

Closed Luciam91 closed 4 months ago

Luciam91 commented 6 years ago

Hello,

We are currently using Quill to power our text editor and for the most part it provides most of what we need. However, one thing that we noted with the snow theme was that you can't edit the title of a link, but rather just the URL content.

Most editors will allow for the editing of both the title and URL, I've tried extending the theme to add this option but this seems like a long, cumbersome workaround for something like this.

What I'd like to know is either A) if it's possible this is likely to be added to the core them, or B) If there are any guides on providing our own theme to add this option?

1c7 commented 6 years ago

Hi @Luciam91

Sorry I didn't understand you description.

here is Quill playground codepen:

https://codepen.io/1c7/pen/KxzMEW

if you want edit title:

image just edit it like text image

If you want edit link

image image

1c7 commented 6 years ago

oh wait. you mean <a title='x'> ? when mouse hover, it show title. that's what you mean right?

1c7 commented 6 years ago

image image (I am editing this directly in Chrome developer tool)

Luciam91 commented 6 years ago

Hi!

Thanks for the response, and I can understand the confusion!

What I meant was changing the content within the Quill Editor, while it is possible to change text within the middle of the link, it's not possible to add text to the start or the end. This is how it is possible in Google Docs: Google Docs

I hope this helps you understand what I mean a bit better

1c7 commented 6 years ago

@Luciam91

base on my current understanding.
Quill doesn't provide this kind of edit right out of box.
you have to code this yourself. ( I just build one few days ago, with Vue.js )

btw I am also just a user of Quill. not core team developer.

rawkode commented 6 years ago

@1c7 Can you share your code for this?

1c7 commented 6 years ago

@rawkode Sure I can share 😄 I have to do a a little bit of code clean up.
because I am using Quill in a Vue.js SPA App(vuex, vue-router, single file component etc) I may do it tonight or tomorrow. I have work to do now.

I would post code here

rawkode commented 6 years ago

Thanks @1c7

1c7 commented 6 years ago

Link input ("Text" & "Link")

Final Result showcase

Click insert link button at rightmost of menu bar image Now "input link prompt" pop out image Input name and URL image Now it's successfully insert! image From Chrome console you can see the format is correct image

Step 1

Use custom toolbar. not quill default one, so we can handle Link button click logic. (I remember there are other way to handle logic, but in this example let's just continue with our custom toolbar)

Something like this. (This is HTML but written in Pug):

#toolbar-container
    #link_button(@click='add_link')
      img(src="/static/editor-icon/editor-new/link_2.svg" style='width:18px;')
#editor_wrapper
    #editor

The key here is #link_button. we gonna write add_link method later.

Let's create a quill editor

      this.editor = new Quill('#editor', {
        modules: {
          toolbar: '#toolbar-container'
        },
        theme: 'snow',
      });

Step 2

let's write add_link

    add_link(){
      this.show_add_link_popup = true;
    }

yes I am using Vue.js so here show_add_link_popup does only one thing. show input box. image

and then, let's insert Link

this.editor.focus();
const index = (this.editor.getSelection() || {}).index || this.editor.getLength() - 1;
this.editor.insertText(index, 'Google', 'link', "https://Google.com");
1c7 commented 6 years ago

because I want

<a target="_blank"></a>

Here is LinkBolt I am using. I remember I copy this from Quill tutorial

      let Inline = Quill.import('blots/inline');
      class LinkBlot extends Inline {
        static create(url) {
          let node = super.create();
          node.setAttribute('href', url);
          node.setAttribute('target', '_blank'); // HERE
          return node;
        }
        static formats(node) {
          return node.getAttribute('href');
        }
      }
      LinkBlot.blotName = 'link'; // use this name
      LinkBlot.tagName = 'a';
      Quill.register(LinkBlot);
1c7 commented 6 years ago

The key part is just one line

quill.insertText(index, 'Google', 'link', "https://Google.com");
1c7 commented 6 years ago

@rawkode I hope this is helpful.

rawkode commented 6 years ago

Thank you very much, @1c7. That's awesome

1c7 commented 6 years ago

Hi @Luciam91 have you solve your problem? if so, can you close this issue? if not, you could keep posting question here

rawkode commented 6 years ago

@1c7 I can't work out how this code helps editing of an already inserted link. Am I missing something? :)

1c7 commented 6 years ago

@rawkode Oh, my code above just insert link. you can't edit it with that box popup again.
that part I didn't code.

image (for above screenshot, click "Link" button (that button I point with Red Arrow) would add new link, not edit)

if you want that:

when click "Link" button:
   if cursor within link:
       it's edit
   else
      it's add new link
nereuseng commented 5 years ago

@1c7 Hi, How do i override the tool-tip shown default behavior when I click the Link? At first, I was tried to use default tool-tip to edit link, but the whole input text will missing. Could you share how you solved this problem? Thanks in advance. :)

image

image

image

1c7 commented 5 years ago

@nereuseng Sorry I can't answer your question. I am not using Quill since (I think) August 2018 or so. I am working on a Wechat mini App now (it's like a app but use html/css/js, not Kotlin/Swift, run on Wechat, cross platform)

I can't remember how to do that. sorry

1c7 commented 5 years ago

At first, our website can write content with format, like a blog. (That's why I try Quill)

but seem like people spend time on phone far more than computer(desktop/laptop)

so we just pivot into an App only product. and we lower the bar for user post content again and again.

now we look like Instagram. ah.

1c7 commented 5 years ago

I am quite disappoint that they don't want write official guide on how to store & display content. see here: https://github.com/quilljs/quill/issues/2276#issuecomment-455401711

nereuseng commented 5 years ago

@1c7 Thank you for your reply, seems like I need to try some luck.

ArsalanSavand commented 4 years ago

For those who are still looking for a solution, I posted my code here with full explaination, goodluck ) https://coding.gonevis.com/how-to-edit-target-attribute-in-quilljs-links/

markleppke commented 2 years ago

Dear QuillJS,

Please look at supporting this request. The user experience around the link tool in quill leaves much to be desired. During usability tests we have users clicking on the link formatting tool expecting the link button to open a dialog allowing them to enter display text and the URI. Due to expectations from other tools like word or google docs. And from how other Quill tools behave.

image

As you can see above, in microsoft word, user can edit both the link display text and URI without first selecting text. This handles user interactions more gracefully, vs forcing them to know they have to first select text to start with. Other quill tool formats allow a user to click first, then type, quillJS immediately starts using the new format. E.g. if I use a blank instance of quill and click "B" to bold text and start typing, the following text is bold. Quill already teaches users that you can first click a button and expect quill to start formatting as expected. However in this one case, link tool, you force users to first type and then select text to enter the details.

Secondly if a user wants to edit the display text and the URI address they have to do it in two separate steps with quill. So if they misspell the display text before they click the link tool, enter in the URI, they need to go back to the other "mode" in order to update the display text.

Thanks,

Mark

equilerex commented 2 years ago

Came up with a fairly heavy frankensteing of a solution for this since being able to edit the text and the target were a hard requirements on a project i worked on (and we already used too much of it for it to be worh picking a new library). for those poor souls who need to tackle similar issues in the future, maybe this will give you some ideas: (built for angular in typesript)

import { EventEmitter, Injectable, SecurityContext } from '@angular/core';
import * as Quill from 'quill';
import MagicUrl from 'quill-magic-url';
import { DomSanitizer } from '@angular/platform-browser';
import { QuillLinkEditorModalComponent } from '../components/quill-link-editor-modal/quill-link-editor-modal.component';
import { ModalService } from './modal/modal.service';

// service for being able to change the text and target of the link/format within quillJs through a custom popup modal
// Note: this is a bit of a frankenstein but there were no easy ways to handle the target attr without modifying the framework.
let rootSanitizer = null;
@Injectable()
export class QuillExtendService {
  constructor(private modalService: ModalService, private sanitizer: DomSanitizer) {
    const rootModalService = this.modalService;
    rootSanitizer = this.sanitizer;
    const Link = Quill.import('formats/link');

    // note: this will extend the link universally across all quill instances which are open.
    class LinkBlot extends Link {
      static create(value) {
        // fallback to when link is just an url string (default quill behaviour)
        const urlOnly = typeof value === 'string';
        const node = super.create(urlOnly ? value : value?.url);
        node.setAttribute('href', urlOnly ? value : value?.url);
        node.setAttribute('target', urlOnly ? '_blank' : value?.target || '_blank');
        node.setAttribute('rel', 'noreferrer noopener');
        node.setAttribute('contenteditable', 'false');

        // handle editing of created links
        // Note: we have hidden the native quilljs .ql-tooltip through css
        node.addEventListener('click', function (e) {
          e.preventDefault();
          e.stopPropagation();
          rootModalService
            .openWithConfig(QuillLinkEditorModalComponent, {
              href: node.href,
              text: node.innerText,
              target: node.target || '_blank'
            })
            .result.subscribe((result) => {
              if (result.type === 'CONFIRM') {
                node.innerText = result.value.text;
                node.setAttribute('href', result.value.href);
                node.setAttribute('target', result.value.target);
              }
            });
        });
        return node;
      }
      static formats(domNode) {
        return { url: domNode.getAttribute('href'), target: domNode.getAttribute('target') };
      }

      // native quill sanitise would strip the target attr, so replace with angular
      static sanitize(url) {
        return rootSanitizer.sanitize(SecurityContext.URL, url) || '';
      }

      format(name, value) {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        if (name !== this.statics.blotName || !value?.url) return super.format(name, value?.url);

        // native quill sanitise here would strip the target attr
        const sanitizedUrl = rootSanitizer.sanitize(SecurityContext.URL, value?.url);
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        this.domNode.setAttribute('href', sanitizedUrl);
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-ignore
        this.domNode.setAttribute('target', value?.target || '_blank');
      }
    }
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    LinkBlot.blotName = 'link';
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    LinkBlot.tagName = 'a';

    Quill.register('formats/link', LinkBlot, true);
    // Add links to quill editor and make them clickable
    Quill.register('modules/magicUrl', MagicUrl, true);
  }

  //  Creating of new links through the toolbar
  quillToolbarLinkHandler({ editorInstance }) {
    editorInstance.focus();
    const range = editorInstance.getSelection();
    this.modalService
      .openWithConfig(QuillLinkEditorModalComponent, {
        href: '',
        text: editorInstance.getText(range.index, range.length),
        target: '_blank',
        // used for hiding the "text" input in case you are applying a link to a selection
        // (only url available then - otherwise if you have multiple blobs selected, it would get messy, so this is the easy workaround)
        selection: range.length > 0
      })
      .result.subscribe((result) => {
        if (result.type === 'CONFIRM') {
          const { index } = editorInstance.getSelection(true);
          const nextIndex = index + result.value.text?.length || 0;
          // adding a whole new blob
          if (range.length == 0) {
            // just add the text first - doing it this way will help avoiding issues with text caret caused by contenteditable=true on the html element
            editorInstance.insertText(index, result.value.text);
            editorInstance.insertText(nextIndex, ' ');
            editorInstance.setSelection(index, result.value.text.length);
          }
          editorInstance.format('link', { url: result.value.href, target: result.value.target }, Quill.sources.USER);
          editorInstance.setSelection(nextIndex + 1, 0);
        }
      });
  }

  // clipboard.convert() used for converting html to quill needs link handling as well
  quillClipboardLinkHandler(node, delta) {
    if (node.target) {
      const Delta = Quill.import('delta');
      const newDelta = new Delta().insert(node.textContent, {
        ...(delta.ops[0].attributes || {}),
        // unable to set "link: { url, target }" here directly since it is overwritten later somewhere in the plugin. so using temporaryPasteTarget which needs to be cleaned additionally through quillClipboardPostConvert()
        temporaryPasteTarget: node.target || '_blank'
      });
      return newDelta;
    }

    return delta;
  }

  // needs to be applied to clipboard.convert() if quillClipboardLinkHandler is used.
  quillClipboardPostConvertCleanup(deltaOps) {
    return {
      ...deltaOps,
      ops: deltaOps.ops.map((op) => {
        if (op?.attributes?.temporaryPasteTarget) {
          return {
            ...op,
            attributes: {
              link: {
                url: op.attributes.link,
                target: op.attributes.temporaryPasteTarget
              }
            }
          };
        }
        return op;
      })
    };
  }

  // backspace does not work well with contentEditable=true (like the link format)
  // where selection enters inside the element and then is unable to continue - delete the whole block instead when we reach it
  quillBackspaceFix() {
    return {
      bindings: {
        list: {
          key: 'backspace',
          context: {
            format: ['list']
          },
          handler: function (range, context) {
            if (range.index === 0 && range.length === 0) return;
            if (range.length === 0) {
              const size = context?.format?.link ? context.prefix.length : 1;
              this.quill.deleteText(range.index - size, size, Quill.sources.USER);
            } else {
              this.quill.deleteText(range, Quill.sources.USER);
            }
          }
        }
      }
    };
  }
}

and to apply it:

// create quill instance
    this._quillEditor = new (<any>Quill)(this.editorElem, {
      modules: {
        magicUrl: !!this.htmlMode,
        toolbar: {
          container: this.htmlMode ? toolbarOptions : null,
          handlers: {
            link: () => {
              this.quillExtendService.quillToolbarLinkHandler({ editorInstance: this._quillEditor });
            },
          },
        },
        clipboard: {
          matchers: [[Node.ELEMENT_NODE, this.quillExtendService.quillClipboardLinkHandler]],
        },
        keyboard: this.quillExtendService.quillBackspaceFix(),
      },
      placeholder: this.placeholder.trim(),
      readOnly: this.readOnly,
      theme: 'snow',
      formats: [...(this.htmlMode ? htmlFormats : []), 'interpolate'],
      bounds: this.editorElem,
    });

essentially:

image image

note the service also includes a bit of quillClipboardPostConvertCleanup - that part is not really needed unless dealing with more complex bits (like using the clipboard.convert( directly for converting html to quill. for reference, this is how i used it (but totally not relevant for the link editing fix, other than perhaps highlighting the fact that custom solutions could potentially break some things down the line)

 // convert html to quill delta
  htmlToQuillDelta(text) {
    // separate the interpolate from normal text
    // const deltaArray = text.split(/(#[\w\.\:]+#)/g);
    const htmlToDeltaArray = this.quillExtendService.quillClipboardPostConvertCleanup(
      this._quillEditor.clipboard.convert(text),
    );
    // convert to quill friendly format

    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    htmlToDeltaArray.ops = htmlToDeltaArray.ops.reduce((acc, val) => {
      // eslint-disable-next-line no-useless-escape
      // interpolate is a custome blot for inserting user data.
      const interpolatedValueArray: string[] = val.insert.split(/#([\w\.\:]+)#/g);
      if (interpolatedValueArray.length >= 3) {
        const convertedOps = [];
        interpolatedValueArray.forEach((value, i) => {
          // odd numbers are normal text
          if (i % 2 === 0) {
            const hashRegexp = new RegExp('#([^\\s]*)', 'g');
            const cleanedValue = value.replace(hashRegexp, '');
            if (cleanedValue) {
              convertedOps.push({ insert: cleanedValue, attributes: val.attributes });
            }
            // even numbers are the interpolate tags
          } else {
            convertedOps.push(this.buildInterpolateBlob(value, val.attributes));
          }
        });
        acc = [...acc, ...convertedOps];
      } else {
        acc = [...acc, val];
      }

      return acc;
    }, []);

    return htmlToDeltaArray;
  }

this was then used in conjuntion with

const value = // pure html string
`this._quillEditor.setContents(this.htmlToQuillDelta(value));`
SJNBham commented 1 year ago

We'd like to see this enhancement built into the standard editor as well.

Thanks

@Luciam91 - see the following for some additional link management enhancements you might chime in on:

https://github.com/quilljs/quill/issues/3860 https://github.com/quilljs/quill/issues/3859 https://github.com/quilljs/quill/issues/3858

nomiich05 commented 6 months ago

here is what I have implement for adding both the Title and URL

import { employeeService } from '@/app/services/employee/employee.service'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Quill } from 'react-quill';

interface UseQuillEditorParams { isMention: boolean; isVideoEmbedEnabled: boolean; }

export function useQuillEditor({ isMention, isVideoEmbedEnabled, }: UseQuillEditorParams) { const editorRef = useRef(null);

const [showModal, setShowModal] = useState(false);
const [modalLinkTitle, setModalLinkTitle] = useState('');
const [modalLinkUrl, setModalLinkUrl] = useState('');

const openLinkModalWithSelectedText = () => {
    const quillEditor = editorRef.current.getEditor();

    let range = quillEditor.getSelection();

    if (!range) {
        range = { index: quillEditor.getLength(), length: 0 };
    }

    const text =
        range && range.length > 0
            ? quillEditor.getText(range.index, range.length)
            : '';

    setModalLinkTitle(text);
    setModalLinkUrl('http://');

    setShowModal(true);

};

const customLinkHandler = () => {
    openLinkModalWithSelectedText();
};

const submitLinkModal = () => {
    const quillEditor = editorRef.current.getEditor();
    const range = quillEditor.getSelection(true); 

    if (modalLinkUrl) {
      if (range && range.length > 0) {
        quillEditor.deleteText(range.index, range.length);
      }

      const insertText = modalLinkTitle || modalLinkUrl; 
      const linkCursorPos = range.index + insertText.length; 

      quillEditor.insertText(range.index, insertText, 'link', modalLinkUrl);

      quillEditor.setSelection(linkCursorPos, 0);
    }

    setShowModal(false);
    setModalLinkTitle('');
    setModalLinkUrl('');
  };

const videoButtonHandler = useCallback(() => {
    if (editorRef.current) {
        const quillEditor = editorRef.current.getEditor();
        const tooltip = quillEditor.theme.tooltip;
        tooltip.show();
        tooltip.edit('video');
        tooltip.textbox.focus();
    }
}, []);

const modules = useMemo(() => {
    let toolbarOptions = [
        ['bold', 'italic', 'underline'],
        [{ list: 'ordered' }, { list: 'bullet' }],
        [{ align: [] }],
        ['link'],
        ['strike'],
        ...(isVideoEmbedEnabled ? [['video']] : []),
    ];

    return {
        mention: isMention
            ? {
                    allowedChars: /^[A-Za-z\sÅÄÖåäö]*$/,
                    mentionDenotationChars: ['@'],
                    spaceAfterInsert: true,
                    isolateCharacter: true,
                    source: async (
                        searchTerm: string,
                        renderItem: (
                            arg0:
                                | { id: number; value: string }[]
                                | undefined,
                            arg1: any,
                        ) => void,

                        mentionChar: string,
                    ) => {
                        const payload = {
                            search: {
                                name: searchTerm,
                            },
                        };

                        const { data: list } = await employeeService.list(
                            payload,
                        );

                        const formattedData = list.map((item: any) => ({
                            id: item.id,
                            value: item.name,
                            link: `/${item.username}/profile`,
                        }));

                        renderItem(formattedData, searchTerm);
                    },
              }
            : undefined,
        toolbar: {
            container: toolbarOptions,
            handlers: {
                video: isVideoEmbedEnabled ? videoButtonHandler : () => {},
            },
        },
    };
}, [isMention, isVideoEmbedEnabled, videoButtonHandler]);

useEffect(() => {
    if (editorRef.current) {
        const quillEditor = editorRef.current.getEditor();

        quillEditor
            .getModule('toolbar')
            .addHandler('video', videoButtonHandler);

        const toolbar = quillEditor.getModule('toolbar');
        toolbar.addHandler('link', customLinkHandler);
    }

    if (typeof window !== 'undefined') {
        require('quill-mention');

        const Quill = require('react-quill').Quill;
        const MentionBlot = Quill.import('blots/mention');

        class StyledMentionBlot extends MentionBlot {
            static create(value: any) {
                const link = document.createElement('a');
                link.href = value.link;
                link.innerText = value.value;

                return link;
            }
        }

        StyledMentionBlot.blotName = 'mention';
        Quill.register(StyledMentionBlot);
    }
}, [videoButtonHandler, isMention, customLinkHandler]);

return {
    editorRef,
    modules,
    showModal,
    setShowModal,
    modalLinkTitle,
    setModalLinkTitle,
    modalLinkUrl,
    setModalLinkUrl,
    submitLinkModal,
};

}

quill-bot commented 4 months ago

Quill 2.0 has been released (announcement post) with many changes and fixes. If this is still an issue please create a new issue after reviewing our updated Contributing guide :pray:

ludejun commented 4 months ago

You can use quill-react-commercial.

It can edit link's text and url again. quill-copy-link If you want to know the source code, you can see it in https://github.com/ludejun/quill-react-commercial/blob/master/modules/toolbar/link.js