UNPKG

suneditor

Version:

Vanilla JavaScript based WYSIWYG web editor

662 lines (592 loc) 24.1 kB
import SelectMenu from './SelectMenu'; import FileManager from '../manager/FileManager'; import { dom, numbers, env, unicode } from '../../helper'; const { _w, NO_EVENT } = env; /** * @typedef {Object} ModalAnchorEditorParams * @property {boolean} [title=false] - Modal title display. * @property {boolean} [textToDisplay=''] - Create Text to display input. * @property {boolean} [openNewWindow=false] - Default checked value of the "Open in new window" checkbox. * @property {boolean} [noAutoPrefix=false] - If `true`, disables the automatic prefixing of the host URL to the value of the link. * @property {Array<string>} [relList=[]] - Available `rel` attribute values shown as checkboxes in the link modal. * @property {{default?: string, check_new_window?: string, check_bookmark?: string}} [defaultRel={}] - Default `rel` values auto-applied by condition. * `default` is always applied, `check_new_window` when "Open in new window" is checked, `check_bookmark` for bookmark links. * ```js * { * relList: ['nofollow', 'noreferrer', 'noopener'], * defaultRel: { default: 'noopener', check_new_window: 'noreferrer' } * } * ``` * @property {string} [uploadUrl] - File upload URL. * - The server must return: * ```js * { * "result": [ * { * "url": "https://example.com/file.pdf", * "name": "file.pdf", * "size": 1048576 * } * ] * } * ``` * @property {Object<string, string>} [uploadHeaders] - File upload headers. * @property {number} [uploadSizeLimit] - File upload size limit. * @property {number} [uploadSingleSizeLimit] - File upload single size limit. * @property {string} [acceptedFormats] - File upload accepted formats. * @property {boolean} [enableFileUpload] - If `true`, enables file upload. */ /** * @class * @description Modal form Anchor tag editor * - Use it by inserting it into Modal in a plugin that uses Modal. */ class ModalAnchorEditor { #$; #modalForm; #isRel; #selectMenu_rel; #selectMenu_bookmark; /** * @constructor * @param {SunEditor.Deps} $ Kernel dependencies * @param {HTMLElement} modalForm Modal <form> * @param {ModalAnchorEditorParams} params ModalAnchorEditor options * @example * // In a link plugin (text anchor): * this.anchor = new ModalAnchorEditor(this.$, modalEl, this.pluginOptions); * * // In an image plugin (non-text anchor with custom options): * const linkOptions = this.$.plugins.link ? this.$.plugins.link.pluginOptions : {}; * this.anchor = new ModalAnchorEditor(this.$, modalEl.html, { * ...linkOptions, * textToDisplay: false, * title: true, * }); */ constructor($, modalForm, params) { this.#$ = $; // params this.openNewWindow = !!params.openNewWindow; this.relList = Array.isArray(params.relList) ? params.relList : []; this.defaultRel = params.defaultRel || {}; this.noAutoPrefix = !!params.noAutoPrefix; // file upload if (params.enableFileUpload) { this.uploadUrl = typeof params.uploadUrl === 'string' ? params.uploadUrl : null; this.uploadHeaders = params.uploadHeaders || null; this.uploadSizeLimit = numbers.get(params.uploadSizeLimit, 0) || null; this.uploadSingleSizeLimit = numbers.get(params.uploadSingleSizeLimit, 0) || null; this.input = dom.utils.createElement('input', { type: 'file', accept: params.acceptedFormats || '*' }); this.#$.eventManager.addEvent(this.input, 'change', this.#OnChangeFile.bind(this)); // file manager this.fileManager = new FileManager(this, $, { query: 'a[download]:not([data-se-file-download])', loadEventName: 'onFileLoad', actionEventName: 'onFileAction', }); } // create HTML const forms = CreateModalForm($, params, this.relList); // members this.host = (_w.location.origin + _w.location.pathname).replace(/\/$/, ''); /** @type {HTMLInputElement} */ this.urlInput = forms.querySelector('.se-input-url'); /** @type {HTMLInputElement} */ this.displayInput = forms.querySelector('._se_display_text'); /** @type {HTMLInputElement} */ this.titleInput = forms.querySelector('._se_title'); /** @type {HTMLInputElement} */ this.newWindowCheck = forms.querySelector('._se_anchor_check'); /** @type {HTMLInputElement} */ this.downloadCheck = forms.querySelector('._se_anchor_download'); /** @type {HTMLElement} */ this.download = forms.querySelector('._se_anchor_download_icon'); /** @type {HTMLElement} */ this.preview = forms.querySelector('.se-link-preview'); /** @type {HTMLElement} */ this.bookmark = forms.querySelector('._se_anchor_bookmark_icon'); /** @type {HTMLButtonElement} */ this.bookmarkButton = forms.querySelector('._se_bookmark_button'); this.currentRel = []; this.currentTarget = null; this.linkValue = ''; this.#isRel = this.relList.length > 0; // members - rel if (this.#isRel) { /** @type {HTMLButtonElement} */ this.relButton = forms.querySelector('.se-anchor-rel-btn'); /** @type {HTMLElement} */ this.relPreview = forms.querySelector('.se-anchor-rel-preview'); const relList = this.relList; const defaultRel = (this.defaultRel.default || '').split(' '); const list = []; for (let i = 0, len = relList.length, rel; i < len; i++) { rel = relList[i]; list.push( dom.utils.createElement( 'BUTTON', { type: 'button', class: 'se-btn-list' + (defaultRel.includes(rel) ? ' se-checked' : ''), 'data-command': rel, title: rel, 'aria-label': rel, }, rel + '<span class="se-svg">' + this.#$.icons.checked + '</span>', ), ); } this.#selectMenu_rel = new SelectMenu($, { checkList: true, position: 'right-middle', dir: 'ltr' }); this.#selectMenu_rel.on(this.relButton, this.#SetRelItem.bind(this)); this.#selectMenu_rel.create(list); this.#$.eventManager.addEvent(this.relButton, 'click', this.#OnClick_relbutton.bind(this)); } // init this.#modalForm = /** @type {HTMLElement} */ (modalForm); this.#modalForm.querySelector('.se-anchor-editor').appendChild(forms); this.#selectMenu_bookmark = new SelectMenu($, { checkList: false, position: 'bottom-left', dir: 'ltr' }); this.#selectMenu_bookmark.on(this.urlInput, this.#SetHeaderBookmark.bind(this)); this.#$.eventManager.addEvent(this.newWindowCheck, 'change', this.#OnChange_newWindowCheck.bind(this)); this.#$.eventManager.addEvent(this.downloadCheck, 'change', this.#OnChange_downloadCheck.bind(this)); this.#$.eventManager.addEvent(this.urlInput, 'input', this.#OnChange_urlInput.bind(this)); this.#$.eventManager.addEvent(this.urlInput, 'focus', this.#OnFocus_urlInput.bind(this)); this.#$.eventManager.addEvent(this.bookmarkButton, 'click', this.#OnClick_bookmarkButton.bind(this)); this.#$.eventManager.addEvent(forms.querySelector('._se_upload_button'), 'click', () => this.input.click()); } /** * @description Initialize. * - Sets the current anchor element to be edited. * @param {Node} element Modal target element */ set(element) { this.currentTarget = /** @type {HTMLAnchorElement} */ (element); } /** * @description Opens the anchor editor modal and populates it with data. * @param {boolean} isUpdate - Indicates whether an existing anchor is being updated (`true`) or a new one is being created (`false`). * @example * // Called from modalOn() — populate form for a new link: * this.anchor.on(false); * * // Populate form to edit an existing link (call set() first): * this.anchor.set(existingAnchorElement); * this.anchor.on(true); */ on(isUpdate) { if (!isUpdate) { this.init(); this.displayInput.value = this.#$.selection.get().toString().trim(); this.newWindowCheck.checked = this.openNewWindow; this.titleInput.value = ''; } else if (this.currentTarget) { const href = this.currentTarget.href; this.linkValue = this.preview.textContent = this.urlInput.value = this.#selfPathBookmark(href) ? href.substring(href.lastIndexOf('#')) : href; this.displayInput.value = this.currentTarget.textContent; this.titleInput.value = this.currentTarget.title; this.newWindowCheck.checked = /_blank/i.test(this.currentTarget.target) ? true : false; this.downloadCheck.checked = !!this.currentTarget.download; } this.#setRel(isUpdate && this.currentTarget ? this.currentTarget.rel : this.defaultRel.default || ''); this.#setLinkPreview(this.linkValue); } /** * @description Creates an anchor (`<a>`) element with the specified attributes. * @param {boolean} notText - If `true`, the anchor will not contain text content. * @returns {HTMLElement|null} - The newly created anchor element, or `null` if the URL is empty. * @example * // In a link plugin — create anchor with text content: * const oA = this.anchor.create(false); * if (oA === null) return false; * this.$.html.insertNode(oA); * * // In an image plugin — create anchor without text (wraps an image): * const anchor = this.anchor.create(true); * if (anchor) { * anchor.appendChild(imgElement); * } */ create(notText) { if (this.linkValue.length === 0) return null; const url = this.linkValue; const displayText = this.displayInput.value.length === 0 ? url : this.displayInput.value; const oA = /** @type {HTMLAnchorElement} */ (this.currentTarget || dom.utils.createElement('A')); this.#updateAnchor(oA, url, displayText, this.titleInput.value, notText); this.linkValue = this.preview.textContent = this.urlInput.value = this.displayInput.value = ''; return oA; } /** * @description Resets the ModalAnchorEditor to its initial state. */ init() { this.currentTarget = null; this.linkValue = this.preview.textContent = this.urlInput.value = ''; this.displayInput.value = ''; this.newWindowCheck.checked = false; this.downloadCheck.checked = false; this.#setRel(this.defaultRel.default || ''); } /** * @description Updates the anchor element with new attributes. * @param {HTMLAnchorElement} anchor - The anchor (`<a>`) element to update. * @param {string} url - The URL for the anchor's `href` attribute. * @param {string} displayText - The text to be displayed inside the anchor. * @param {string} title - The tooltip text (title attribute). * @param {boolean} notText - If `true`, the anchor will not contain text content. */ #updateAnchor(anchor, url, displayText, title, notText) { // download if (!this.#selfPathBookmark(url) && this.downloadCheck.checked) { anchor.setAttribute('download', displayText || url); } else { anchor.removeAttribute('download'); } // new window if (this.newWindowCheck.checked) anchor.target = '_blank'; else anchor.removeAttribute('target'); // rel const rel = this.currentRel.join(' '); if (!rel) anchor.removeAttribute('rel'); else anchor.rel = rel; // set url anchor.href = url; if (title) anchor.title = title; else anchor.removeAttribute('title'); if (notText) { if (anchor.children.length === 0) anchor.textContent = ''; } else { anchor.textContent = displayText; } } /** * @description Checks if the given path is an internal bookmark. * @param {string} path - The URL or anchor link. * @returns {boolean} - `true` if the path is an internal bookmark, otherwise `false`. */ #selfPathBookmark(path) { const href = _w.location.href.replace(/\/$/, ''); return path.indexOf('#') === 0 || (path.indexOf(href) === 0 && path.indexOf('#') === (!href.includes('#') ? href.length : href.substring(0, href.indexOf('#')).length)); } /** * @description Updates the `rel` attribute list in the modal and preview. * @param {string} relAttr - The `rel` attribute string to set. */ #setRel(relAttr) { if (!this.#isRel) return; const rels = (this.currentRel = !relAttr ? [] : relAttr.split(' ')); const checkedRel = this.#selectMenu_rel.form.querySelectorAll('button'); for (let i = 0, len = checkedRel.length, cmd; i < len; i++) { cmd = checkedRel[i].getAttribute('data-command'); if (rels.includes(cmd)) { dom.utils.addClass(checkedRel[i], 'se-checked'); } else { dom.utils.removeClass(checkedRel[i], 'se-checked'); } } this.relPreview.title = this.relPreview.textContent = rels.join(' '); if (rels.length > 0) { dom.utils.addClass(this.relButton, 'on'); } else { dom.utils.removeClass(this.relButton, 'on'); } } /** * @description Generates a list of bookmark headers within the editor. * @param {string} urlValue - The current URL input value. */ #createBookmarkList(urlValue) { const headers = dom.query.getListChildren(this.#$.frameContext.get('wysiwyg'), (current) => /h[1-6]/i.test(current.nodeName) || (dom.check.isAnchor(current) && !!current.id), null); if (headers.length === 0) return; const valueRegExp = new RegExp(`^${urlValue.replace(/^#/, '')}`, 'i'); const list = []; const menus = []; for (let i = 0, len = headers.length, v; i < len; i++) { v = headers[i]; if (!valueRegExp.test(v.textContent)) continue; list.push(v); menus.push(dom.check.isAnchor(v) ? `<div><span class="se-text-prefix-icon">${this.#$.icons.bookmark_anchor}</span>${v.id}</div>` : `<div style="${v.style.cssText}">${v.textContent}</div>`); } if (list.length === 0) { this.#selectMenu_bookmark.close(); } else { this.#selectMenu_bookmark.create(list, menus); this.#selectMenu_bookmark.open(this.#$.options.get('_rtl') ? 'bottom-right' : ''); } } /** * @description Updates the preview of the anchor link. * @param {string} value - The current URL value. */ #setLinkPreview(value) { const preview = this.preview; const protocol = this.#$.options.get('defaultUrlProtocol'); const noPrefix = this.noAutoPrefix; const reservedProtocol = /^(mailto:|tel:|sms:|https*:\/\/|#)/.test(value) || value.indexOf(protocol) === 0; const sameProtocol = !protocol ? false : RegExp('^' + unicode.escapeStringRegexp(value.substring(0, protocol.length))).test(protocol); value = this.linkValue = preview.textContent = !value ? '' : noPrefix ? value : protocol && !reservedProtocol && !sameProtocol ? protocol + value : reservedProtocol ? value : /^www\./.test(value) ? 'http://' + value : this.host + (/^\//.test(value) ? '' : '/') + value; if (this.#selfPathBookmark(value)) { this.bookmark.style.display = 'block'; dom.utils.addClass(this.bookmarkButton, 'active'); } else { this.bookmark.style.display = 'none'; dom.utils.removeClass(this.bookmarkButton, 'active'); } if (!this.#selfPathBookmark(value) && this.downloadCheck.checked) { this.download.style.display = 'block'; } else { this.download.style.display = 'none'; } } /** * @description Merges the given `rel` attribute value with the current list. * @param {string} relAttr - The `rel` attribute to merge. * @returns {string} - The updated `rel` attribute string. */ #relMerge(relAttr) { const current = this.currentRel; if (!relAttr) return current.join(' '); if (/^only:/.test(relAttr)) { relAttr = relAttr.replace(/^only:/, '').trim(); this.currentRel = relAttr.split(' '); return relAttr; } const rels = relAttr.split(' '); for (let i = 0, len = rels.length; i < len; i++) { if (!current.includes(rels[i])) current.push(rels[i]); } return current.join(' '); } /** * @description Removes the specified `rel` attribute from the current list. * @param {string} relAttr - The `rel` attribute to remove. * @returns {string} - The updated `rel` attribute string. */ #relDelete(relAttr) { if (!relAttr) return this.currentRel.join(' '); if (/^only:/.test(relAttr)) relAttr = relAttr.replace(/^only:/, '').trim(); const rels = this.currentRel.join(' ').replace(RegExp(relAttr + '\\s*'), ''); this.currentRel = rels.split(' '); return rels; } /** * @description Registers a newly uploaded file and sets its URL in the modal form. * @param {Object<string, *>} response - The response object from the file upload request. */ #register(response) { const file = response.result[0]; this.linkValue = this.preview.textContent = this.urlInput.value = file.url; this.displayInput.value = file.name; this.downloadCheck.checked = true; this.download.style.display = 'block'; } /** * @description Handles file upload errors. * @param {Object<string, *>} response - The error response object. * @returns {Promise<void>} */ async #error(response) { const message = await this.#$.eventManager.triggerEvent('onFileUploadError', { error: response }); if (message === false) return; const err = message === NO_EVENT ? response.errorMessage : message || response.errorMessage; this.#$.ui.alertOpen(err, 'error'); console.error('[SUNEDITOR.plugin.fileUpload.error]', err); } /** * @description Handles the callback after a file upload completes. * @param {XMLHttpRequest} xmlHttp - The XMLHttpRequest object containing the response. */ #uploadCallBack(xmlHttp) { const response = JSON.parse(xmlHttp.responseText); if (response.errorMessage) { this.#error(response); } else { this.#register(response); } } /** * @description Handles file input change events. * @param {InputEvent} e - The change event object. */ async #OnChangeFile(e) { /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); const files = eventTarget.files; if (!files[0]) return; const fileInfo = { url: this.uploadUrl, uploadHeaders: this.uploadHeaders, files, }; const handler = async function (uploadCallback, infos, newInfos) { infos = newInfos || infos; const xmlHttp = await this.fileManager.asyncUpload(infos.url, infos.uploadHeaders, infos.files); uploadCallback(xmlHttp); }.bind(this, this.#uploadCallBack.bind(this), fileInfo); const result = await this.#$.eventManager.triggerEvent('onFileUploadBefore', { info: fileInfo, handler, }); if (result === undefined) return true; if (result === false) return false; if (result !== null && typeof result === 'object') handler(result); if (result === true || result === NO_EVENT) handler(null); } /** * @description Opens the `rel` attribute selection menu. */ #OnClick_relbutton() { this.#selectMenu_rel.open(this.#$.options.get('_rtl') ? 'left-middle' : ''); } /** * @description Sets the selected bookmark as the URL. * @param {HTMLElement} item - The selected bookmark element. */ #SetHeaderBookmark(item) { const id = item.id || 'h_' + Math.random().toString().replace(/.+\./, ''); item.id = id; this.urlInput.value = '#' + id; this.#setLinkPreview(this.urlInput.value); this.#selectMenu_bookmark.close(); this.urlInput.focus(); } /** * @param {HTMLElement} item - The selected `rel` attribute element. */ #SetRelItem(item) { const cmd = item.getAttribute('data-command'); if (!cmd) return; const current = this.currentRel; const index = current.indexOf(cmd); if (index === -1) current.push(cmd); else current.splice(index, 1); this.relPreview.title = this.relPreview.textContent = current.join(', '); } /** * @param {InputEvent} e - Event object */ #OnChange_urlInput(e) { /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); const value = eventTarget.value.trim(); this.#setLinkPreview(value); if (this.#selfPathBookmark(value)) this.#createBookmarkList(value); else this.#selectMenu_bookmark.close(); } #OnFocus_urlInput() { const value = this.urlInput.value; if (this.#selfPathBookmark(value)) this.#createBookmarkList(value); } #OnClick_bookmarkButton() { let url = this.urlInput.value; if (this.#selfPathBookmark(url)) { url = url.substring(1); this.bookmark.style.display = 'none'; dom.utils.removeClass(this.bookmarkButton, 'active'); } else { url = '#' + url; this.bookmark.style.display = 'block'; dom.utils.addClass(this.bookmarkButton, 'active'); this.downloadCheck.checked = false; this.download.style.display = 'none'; this.#createBookmarkList(url); } this.urlInput.value = url; this.#setLinkPreview(url); this.urlInput.focus(); } /** * @param {InputEvent} e - Event object */ #OnChange_newWindowCheck(e) { if (typeof this.defaultRel.check_new_window !== 'string') return; /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); if (eventTarget.checked) { this.#setRel(this.#relMerge(this.defaultRel.check_new_window)); } else { this.#setRel(this.#relDelete(this.defaultRel.check_new_window)); } } /** * @param {InputEvent} e - Event object */ #OnChange_downloadCheck(e) { /** @type {HTMLInputElement} */ const eventTarget = dom.query.getEventTarget(e); if (eventTarget.checked) { this.download.style.display = 'block'; this.bookmark.style.display = 'none'; dom.utils.removeClass(this.bookmarkButton, 'active'); this.linkValue = this.preview.textContent = this.urlInput.value = this.urlInput.value.replace(/^#+/, ''); if (typeof this.defaultRel.check_bookmark === 'string') { this.#setRel(this.#relMerge(this.defaultRel.check_bookmark)); } } else { this.download.style.display = 'none'; if (typeof this.defaultRel.check_bookmark === 'string') { this.#setRel(this.#relDelete(this.defaultRel.check_bookmark)); } } } } /** * @param {SunEditor.Deps} $ - Kernel dependencies * @param {ModalAnchorEditorParams} params - ModalAnchorEditor options * @param {Array<string>} relList - REL attribute list * @returns {HTMLElement} - Modal form element */ function CreateModalForm($, params, relList) { const lang = $.lang; const icons = $.icons; const textDisplayShow = params.textToDisplay ? '' : 'style="display: none;"'; const titleShow = params.title ? '' : 'style="display: none;"'; let html = /*html*/ ` <div class="se-modal-body"> <div class="se-modal-form"> <label>${lang.link_modal_url}</label> <div class="se-modal-form-files"> <input data-focus class="se-input-form se-input-url" type="text" placeholder="${$.options.get('defaultUrlProtocol') || ''}" /> ${ params.enableFileUpload ? `<button type="button" class="se-btn se-tooltip se-modal-files-edge-button _se_upload_button" aria-label="${lang.fileUpload}"> ${icons.file_upload} ${dom.utils.createTooltipInner(lang.fileUpload)} </button>` : '' } <button type="button" class="se-btn se-tooltip se-modal-files-edge-button _se_bookmark_button" aria-label="${lang.link_modal_bookmark}"> ${icons.bookmark} ${dom.utils.createTooltipInner(lang.link_modal_bookmark)} </button> </div> <div class="se-anchor-preview-form"> <span class="se-svg se-anchor-preview-icon _se_anchor_bookmark_icon">${icons.bookmark}</span> <span class="se-svg se-anchor-preview-icon _se_anchor_download_icon">${icons.download}</span> <pre class="se-link-preview"></pre> </div> <label ${textDisplayShow}>${lang.link_modal_text}</label> <input class="se-input-form _se_display_text" type="text" ${textDisplayShow} /> <label ${titleShow}>${lang.title}</label> <input class="se-input-form _se_title" type="text" ${titleShow} /> </div> <div class="se-modal-form-footer"> <label><input type="checkbox" class="se-modal-btn-check _se_anchor_check" />&nbsp;${lang.link_modal_newWindowCheck}</label> <label><input type="checkbox" class="se-modal-btn-check _se_anchor_download" />&nbsp;${lang.link_modal_downloadLinkCheck}</label>`; if (relList.length > 0) { html += /*html*/ ` <div class="se-anchor-rel"> <button type="button" class="se-btn se-tooltip se-anchor-rel-btn" title="${lang.link_modal_relAttribute}" aria-label="${lang.link_modal_relAttribute}"> ${icons.link_rel} ${dom.utils.createTooltipInner(lang.link_modal_relAttribute)} </button> <div class="se-anchor-rel-wrapper"><pre class="se-link-preview se-anchor-rel-preview"></pre></div> </div> </div>`; } html += '</div></div>'; return dom.utils.createElement('DIV', null, html); } export default ModalAnchorEditor;