diff --git a/package.json b/package.json index 20f96ff48..264180f81 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "es2021": true }, "extends": "airbnb-base", + "ignorePatterns": ["resources/**/*-stub.js"], "overrides": [ ], "parserOptions": { @@ -76,6 +77,28 @@ "anonymous": "never", "named": "never", "asyncArrow": "always" + }], + "import/prefer-default-export": "off", + "no-plusplus": ["error", { + "allowForLoopAfterthoughts": true + }], + "arrow-body-style": "off", + "no-restricted-syntax": "off", + "no-continue": "off", + "no-console": ["warn", { + "allow": ["error"] + }], + "max-len": ["error", { + "code": 110, + "tabWidth": 4, + "ignoreUrls": true, + "ignoreComments": false, + "ignoreRegExpLiterals": true, + "ignoreStrings": true, + "ignoreTemplateLiterals": true + }], + "no-param-reassign": ["error", { + "props": false }] } } diff --git a/resources/js/app.js b/resources/js/app.js index ccf54b33d..86c8d0802 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,19 +1,20 @@ import events from './services/events'; -import httpInstance from './services/http'; +import * as httpInstance from './services/http'; import Translations from './services/translations'; import * as components from './services/components'; import * as componentMap from './components'; // Url retrieval function -window.baseUrl = function(path) { +window.baseUrl = function baseUrl(path) { + let targetPath = path; let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content'); if (basePath[basePath.length - 1] === '/') basePath = basePath.slice(0, basePath.length - 1); - if (path[0] === '/') path = path.slice(1); - return `${basePath}/${path}`; + if (targetPath[0] === '/') targetPath = targetPath.slice(1); + return `${basePath}/${targetPath}`; }; -window.importVersioned = function(moduleName) { +window.importVersioned = function importVersioned(moduleName) { const version = document.querySelector('link[href*="/dist/styles.css?version="]').href.split('?version=').pop(); const importPath = window.baseUrl(`dist/${moduleName}.js?version=${version}`); return import(importPath); diff --git a/resources/js/markdown/codemirror.js b/resources/js/markdown/codemirror.js index 67b7c68ec..b615e666b 100644 --- a/resources/js/markdown/codemirror.js +++ b/resources/js/markdown/codemirror.js @@ -1,6 +1,6 @@ import {provideKeyBindings} from './shortcuts'; import {debounce} from '../services/util'; -import Clipboard from '../services/clipboard'; +import {Clipboard} from '../services/clipboard'; /** * Initiate the codemirror instance for the markdown editor. diff --git a/resources/js/services/clipboard.js b/resources/js/services/clipboard.js index ecdbecf53..02db29be0 100644 --- a/resources/js/services/clipboard.js +++ b/resources/js/services/clipboard.js @@ -66,5 +66,3 @@ export async function copyTextToClipboard(text) { document.execCommand('copy'); document.body.removeChild(tempInput); } - -export default Clipboard; diff --git a/resources/js/services/http.js b/resources/js/services/http.js index 9f1b5deac..d0d33e317 100644 --- a/resources/js/services/http.js +++ b/resources/js/services/http.js @@ -1,55 +1,100 @@ /** - * Perform a HTTP GET request. - * Can easily pass query parameters as the second parameter. - * @param {String} url - * @param {Object} params - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} + * @typedef FormattedResponse + * @property {Headers} headers + * @property {Response} original + * @property {Object|String} data + * @property {Boolean} redirected + * @property {Number} status + * @property {string} statusText + * @property {string} url */ -async function get(url, params = {}) { - return request(url, { - method: 'GET', - params, - }); + +/** + * Get the content from a fetch response. + * Checks the content-type header to determine the format. + * @param {Response} response + * @returns {Promise} + */ +async function getResponseContent(response) { + if (response.status === 204) { + return null; + } + + const responseContentType = response.headers.get('Content-Type') || ''; + const subType = responseContentType.split(';')[0].split('/').pop(); + + if (subType === 'javascript' || subType === 'json') { + return response.json(); + } + + return response.text(); +} + +export class HttpError extends Error { + + constructor(response, content) { + super(response.statusText); + this.data = content; + this.headers = response.headers; + this.redirected = response.redirected; + this.status = response.status; + this.statusText = response.statusText; + this.url = response.url; + this.original = response; + } + } /** - * Perform a HTTP POST request. + * Create a new HTTP request, setting the required CSRF information + * to communicate with the back-end. Parses & formats the response. * @param {String} url - * @param {Object} data - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} + * @param {Object} options + * @returns {Promise} */ -async function post(url, data = null) { - return dataRequest('POST', url, data); -} +async function request(url, options = {}) { + let requestUrl = url; -/** - * Perform a HTTP PUT request. - * @param {String} url - * @param {Object} data - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} - */ -async function put(url, data = null) { - return dataRequest('PUT', url, data); -} + if (!requestUrl.startsWith('http')) { + requestUrl = window.baseUrl(requestUrl); + } -/** - * Perform a HTTP PATCH request. - * @param {String} url - * @param {Object} data - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} - */ -async function patch(url, data = null) { - return dataRequest('PATCH', url, data); -} + if (options.params) { + const urlObj = new URL(requestUrl); + for (const paramName of Object.keys(options.params)) { + const value = options.params[paramName]; + if (typeof value !== 'undefined' && value !== null) { + urlObj.searchParams.set(paramName, value); + } + } + requestUrl = urlObj.toString(); + } -/** - * Perform a HTTP DELETE request. - * @param {String} url - * @param {Object} data - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} - */ -async function performDelete(url, data = null) { - return dataRequest('DELETE', url, data); + const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); + const requestOptions = {...options, credentials: 'same-origin'}; + requestOptions.headers = { + ...requestOptions.headers || {}, + baseURL: window.baseUrl(''), + 'X-CSRF-TOKEN': csrfToken, + }; + + const response = await fetch(requestUrl, requestOptions); + const content = await getResponseContent(response); + const returnData = { + data: content, + headers: response.headers, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + url: response.url, + original: response, + }; + + if (!response.ok) { + throw new HttpError(response, content); + } + + return returnData; } /** @@ -58,7 +103,7 @@ async function performDelete(url, data = null) { * @param {String} method * @param {String} url * @param {Object} data - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} + * @returns {Promise} */ async function dataRequest(method, url, data = null) { const options = { @@ -87,96 +132,57 @@ async function dataRequest(method, url, data = null) { } /** - * Create a new HTTP request, setting the required CSRF information - * to communicate with the back-end. Parses & formats the response. + * Perform a HTTP GET request. + * Can easily pass query parameters as the second parameter. * @param {String} url - * @param {Object} options - * @returns {Promise<{headers: Headers, original: Response, data: (Object|String), redirected: boolean, statusText: string, url: string, status: number}>} + * @param {Object} params + * @returns {Promise} */ -async function request(url, options = {}) { - if (!url.startsWith('http')) { - url = window.baseUrl(url); - } - - if (options.params) { - const urlObj = new URL(url); - for (const paramName of Object.keys(options.params)) { - const value = options.params[paramName]; - if (typeof value !== 'undefined' && value !== null) { - urlObj.searchParams.set(paramName, value); - } - } - url = urlObj.toString(); - } - - const csrfToken = document.querySelector('meta[name=token]').getAttribute('content'); - options = {...options, credentials: 'same-origin'}; - options.headers = { - ...options.headers || {}, - baseURL: window.baseUrl(''), - 'X-CSRF-TOKEN': csrfToken, - }; - - const response = await fetch(url, options); - const content = await getResponseContent(response); - const returnData = { - data: content, - headers: response.headers, - redirected: response.redirected, - status: response.status, - statusText: response.statusText, - url: response.url, - original: response, - }; - - if (!response.ok) { - throw new HttpError(response, content); - } - - return returnData; +export async function get(url, params = {}) { + return request(url, { + method: 'GET', + params, + }); } /** - * Get the content from a fetch response. - * Checks the content-type header to determine the format. - * @param {Response} response - * @returns {Promise} + * Perform a HTTP POST request. + * @param {String} url + * @param {Object} data + * @returns {Promise} */ -async function getResponseContent(response) { - if (response.status === 204) { - return null; - } - - const responseContentType = response.headers.get('Content-Type') || ''; - const subType = responseContentType.split(';')[0].split('/').pop(); - - if (subType === 'javascript' || subType === 'json') { - return await response.json(); - } - - return await response.text(); +export async function post(url, data = null) { + return dataRequest('POST', url, data); } -class HttpError extends Error { - - constructor(response, content) { - super(response.statusText); - this.data = content; - this.headers = response.headers; - this.redirected = response.redirected; - this.status = response.status; - this.statusText = response.statusText; - this.url = response.url; - this.original = response; - } - +/** + * Perform a HTTP PUT request. + * @param {String} url + * @param {Object} data + * @returns {Promise} + */ +export async function put(url, data = null) { + return dataRequest('PUT', url, data); } -export default { - get, - post, - put, - patch, - delete: performDelete, - HttpError, -}; +/** + * Perform a HTTP PATCH request. + * @param {String} url + * @param {Object} data + * @returns {Promise} + */ +export async function patch(url, data = null) { + return dataRequest('PATCH', url, data); +} + +/** + * Perform a HTTP DELETE request. + * @param {String} url + * @param {Object} data + * @returns {Promise} + */ +async function performDelete(url, data = null) { + return dataRequest('DELETE', url, data); +} + +export {performDelete as delete}; diff --git a/resources/js/services/util.js b/resources/js/services/util.js index df2b31336..dd97d81aa 100644 --- a/resources/js/services/util.js +++ b/resources/js/services/util.js @@ -11,10 +11,9 @@ */ export function debounce(func, wait, immediate) { let timeout; - return function() { - const context = this; const - args = arguments; - const later = function() { + return function debouncedWrapper(...args) { + const context = this; + const later = function debouncedTimeout() { timeout = null; if (!immediate) func.apply(context, args); }; @@ -67,6 +66,7 @@ export function escapeHtml(unsafe) { * @returns {string} */ export function uniqueId() { + // eslint-disable-next-line no-bitwise const S4 = () => (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1); return (`${S4() + S4()}-${S4()}-${S4()}-${S4()}-${S4()}${S4()}${S4()}`); } diff --git a/resources/js/wysiwyg/config.js b/resources/js/wysiwyg/config.js index 89efdb8a0..a0e7156ee 100644 --- a/resources/js/wysiwyg/config.js +++ b/resources/js/wysiwyg/config.js @@ -13,7 +13,7 @@ import {getPlugin as getAboutPlugin} from './plugins-about'; import {getPlugin as getDetailsPlugin} from './plugins-details'; import {getPlugin as getTasklistPlugin} from './plugins-tasklist'; -const style_formats = [ +const styleFormats = [ {title: 'Large Header', format: 'h2', preview: 'color: blue;'}, {title: 'Medium Header', format: 'h3'}, {title: 'Small Header', format: 'h4'}, @@ -43,7 +43,7 @@ const formats = { calloutdanger: {block: 'p', exact: true, attributes: {class: 'callout danger'}}, }; -const color_map = [ +const colorMap = [ '#BFEDD2', '', '#FBEEB8', '', '#F8CAC6', '', @@ -72,7 +72,7 @@ const color_map = [ '#ffffff', '', ]; -function file_picker_callback(callback, value, meta) { +function filePickerCallback(callback, value, meta) { // field_name, url, type, win if (meta.filetype === 'file') { /** @type {EntitySelectorPopup} * */ @@ -119,12 +119,12 @@ function gatherPlugins(options) { options.textDirection === 'rtl' ? 'directionality' : '', ]; - window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin(options)); - window.tinymce.PluginManager.add('customhr', getCustomhrPlugin(options)); - window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin(options)); - window.tinymce.PluginManager.add('about', getAboutPlugin(options)); - window.tinymce.PluginManager.add('details', getDetailsPlugin(options)); - window.tinymce.PluginManager.add('tasklist', getTasklistPlugin(options)); + window.tinymce.PluginManager.add('codeeditor', getCodeeditorPlugin()); + window.tinymce.PluginManager.add('customhr', getCustomhrPlugin()); + window.tinymce.PluginManager.add('imagemanager', getImagemanagerPlugin()); + window.tinymce.PluginManager.add('about', getAboutPlugin()); + window.tinymce.PluginManager.add('details', getDetailsPlugin()); + window.tinymce.PluginManager.add('tasklist', getTasklistPlugin()); if (options.drawioUrl) { window.tinymce.PluginManager.add('drawio', getDrawioPlugin(options)); @@ -156,7 +156,7 @@ function setupBrFilter(editor) { editor.serializer.addNodeFilter('br', nodes => { for (const node of nodes) { if (node.parent && node.parent.name === 'code') { - const newline = tinymce.html.Node.create('#text'); + const newline = window.tinymce.html.Node.create('#text'); newline.value = '\n'; node.replace(newline); } @@ -169,7 +169,14 @@ function setupBrFilter(editor) { * @return {function(Editor)} */ function getSetupCallback(options) { - return function(editor) { + return function setupCallback(editor) { + function editorChange() { + if (options.darkMode) { + editor.contentDocument.documentElement.classList.add('dark-mode'); + } + window.$events.emit('editor-html-change', ''); + } + editor.on('ExecCommand change input NodeChange ObjectResized', editorChange); listenForCommonEvents(editor); listenForDragAndPaste(editor, options); @@ -185,13 +192,6 @@ function getSetupCallback(options) { setupBrFilter(editor); }); - function editorChange() { - if (options.darkMode) { - editor.contentDocument.documentElement.classList.add('dark-mode'); - } - window.$events.emit('editor-html-change', ''); - } - // Custom handler hook window.$events.emitPublic(options.containerElement, 'editor-tinymce::setup', {editor}); @@ -274,7 +274,7 @@ export function build(options) { contextmenu: false, toolbar: getPrimaryToolbar(options), content_style: getContentStyle(options), - style_formats, + style_formats: styleFormats, style_formats_merge: false, media_alt_source: false, media_poster: false, @@ -282,8 +282,8 @@ export function build(options) { table_style_by_css: true, table_use_colgroups: true, file_picker_types: 'file image', - color_map, - file_picker_callback, + color_map: colorMap, + file_picker_callback: filePickerCallback, paste_preprocess(plugin, args) { const {content} = args; if (content.indexOf(''); editor.ui.registry.addButton('codeeditor', { @@ -183,7 +184,7 @@ function register(editor, url) { } }); - editor.on('dblclick', event => { + editor.on('dblclick', () => { const selectedNode = editor.selection.getNode(); if (elemIsCodeBlock(selectedNode)) { showPopupForCodeBlock(editor, selectedNode); @@ -193,7 +194,7 @@ function register(editor, url) { editor.on('PreInit', () => { editor.parser.addNodeFilter('pre', elms => { for (const el of elms) { - const wrapper = tinymce.html.Node.create('code-block', { + const wrapper = window.tinymce.html.Node.create('code-block', { contenteditable: 'false', }); @@ -234,9 +235,8 @@ function register(editor, url) { } /** - * @param {WysiwygConfigOptions} options * @return {register} */ -export function getPlugin(options) { +export function getPlugin() { return register; } diff --git a/resources/js/wysiwyg/plugin-drawio.js b/resources/js/wysiwyg/plugin-drawio.js index 3d1125841..7827efd62 100644 --- a/resources/js/wysiwyg/plugin-drawio.js +++ b/resources/js/wysiwyg/plugin-drawio.js @@ -32,12 +32,6 @@ function showDrawingManager(mceEditor, selectedNode = null) { }, 'drawio'); } -function showDrawingEditor(mceEditor, selectedNode = null) { - pageEditor = mceEditor; - currentNode = selectedNode; - DrawIO.show(options.drawioUrl, drawingInit, updateContent); -} - async function updateContent(pngData) { const id = `image-${Math.random().toString(16).slice(2)}`; const loadingImage = window.baseUrl('/loading.gif'); @@ -48,7 +42,7 @@ async function updateContent(pngData) { } else { window.$events.emit('error', options.translations.imageUploadErrorText); } - console.log(error); + console.error(error); }; // Handle updating an existing image @@ -92,6 +86,66 @@ function drawingInit() { return DrawIO.load(drawingId); } +function showDrawingEditor(mceEditor, selectedNode = null) { + pageEditor = mceEditor; + currentNode = selectedNode; + DrawIO.show(options.drawioUrl, drawingInit, updateContent); +} + +/** + * @param {Editor} editor + */ +function register(editor) { + editor.addCommand('drawio', () => { + const selectedNode = editor.selection.getNode(); + showDrawingEditor(editor, isDrawing(selectedNode) ? selectedNode : null); + }); + + editor.ui.registry.addIcon('diagram', ``); + + editor.ui.registry.addSplitButton('drawio', { + tooltip: 'Insert/edit drawing', + icon: 'diagram', + onAction() { + editor.execCommand('drawio'); + // Hack to de-focus the tinymce editor toolbar + window.document.body.dispatchEvent(new Event('mousedown', {bubbles: true})); + }, + fetch(callback) { + callback([ + { + type: 'choiceitem', + text: 'Drawing manager', + value: 'drawing-manager', + }, + ]); + }, + onItemAction(api, value) { + if (value === 'drawing-manager') { + const selectedNode = editor.selection.getNode(); + showDrawingManager(editor, isDrawing(selectedNode) ? selectedNode : null); + } + }, + }); + + editor.on('dblclick', () => { + const selectedNode = editor.selection.getNode(); + if (!isDrawing(selectedNode)) return; + showDrawingEditor(editor, selectedNode); + }); + + editor.on('SetContent', () => { + const drawings = editor.dom.select('body > div[drawio-diagram]'); + if (!drawings.length) return; + + editor.undoManager.transact(() => { + for (const drawing of drawings) { + drawing.setAttribute('contenteditable', 'false'); + } + }); + }); +} + /** * * @param {WysiwygConfigOptions} providedOptions @@ -99,54 +153,5 @@ function drawingInit() { */ export function getPlugin(providedOptions) { options = providedOptions; - return function(editor, url) { - editor.addCommand('drawio', () => { - const selectedNode = editor.selection.getNode(); - showDrawingEditor(editor, isDrawing(selectedNode) ? selectedNode : null); - }); - - editor.ui.registry.addIcon('diagram', ``); - - editor.ui.registry.addSplitButton('drawio', { - tooltip: 'Insert/edit drawing', - icon: 'diagram', - onAction() { - editor.execCommand('drawio'); - // Hack to de-focus the tinymce editor toolbar - window.document.body.dispatchEvent(new Event('mousedown', {bubbles: true})); - }, - fetch(callback) { - callback([ - { - type: 'choiceitem', - text: 'Drawing manager', - value: 'drawing-manager', - }, - ]); - }, - onItemAction(api, value) { - if (value === 'drawing-manager') { - const selectedNode = editor.selection.getNode(); - showDrawingManager(editor, isDrawing(selectedNode) ? selectedNode : null); - } - }, - }); - - editor.on('dblclick', event => { - const selectedNode = editor.selection.getNode(); - if (!isDrawing(selectedNode)) return; - showDrawingEditor(editor, selectedNode); - }); - - editor.on('SetContent', () => { - const drawings = editor.dom.select('body > div[drawio-diagram]'); - if (!drawings.length) return; - - editor.undoManager.transact(() => { - for (const drawing of drawings) { - drawing.setAttribute('contenteditable', 'false'); - } - }); - }); - }; + return register; } diff --git a/resources/js/wysiwyg/plugins-about.js b/resources/js/wysiwyg/plugins-about.js index a9c0a9e64..096b4f968 100644 --- a/resources/js/wysiwyg/plugins-about.js +++ b/resources/js/wysiwyg/plugins-about.js @@ -1,8 +1,7 @@ /** * @param {Editor} editor - * @param {String} url */ -function register(editor, url) { +function register(editor) { const aboutDialog = { title: 'About the WYSIWYG Editor', url: window.baseUrl('/help/wysiwyg'), @@ -12,15 +11,14 @@ function register(editor, url) { icon: 'help', tooltip: 'About the editor', onAction() { - tinymce.activeEditor.windowManager.openUrl(aboutDialog); + window.tinymce.activeEditor.windowManager.openUrl(aboutDialog); }, }); } /** - * @param {WysiwygConfigOptions} options * @return {register} */ -export function getPlugin(options) { +export function getPlugin() { return register; } diff --git a/resources/js/wysiwyg/plugins-customhr.js b/resources/js/wysiwyg/plugins-customhr.js index 6aa1620da..f5da947f2 100644 --- a/resources/js/wysiwyg/plugins-customhr.js +++ b/resources/js/wysiwyg/plugins-customhr.js @@ -1,8 +1,7 @@ /** * @param {Editor} editor - * @param {String} url */ -function register(editor, url) { +function register(editor) { editor.addCommand('InsertHorizontalRule', () => { const hrElem = document.createElement('hr'); const cNode = editor.selection.getNode(); @@ -20,9 +19,8 @@ function register(editor, url) { } /** - * @param {WysiwygConfigOptions} options * @return {register} */ -export function getPlugin(options) { +export function getPlugin() { return register; } diff --git a/resources/js/wysiwyg/plugins-details.js b/resources/js/wysiwyg/plugins-details.js index 5da6680ed..c4a6d927d 100644 --- a/resources/js/wysiwyg/plugins-details.js +++ b/resources/js/wysiwyg/plugins-details.js @@ -1,10 +1,178 @@ -/** - * @param {Editor} editor - * @param {String} url - */ import {blockElementTypes} from './util'; -function register(editor, url) { +/** + * @param {Editor} editor + */ +function getSelectedDetailsBlock(editor) { + return editor.selection.getNode().closest('details'); +} + +function setSummary(editor, summaryContent) { + const details = getSelectedDetailsBlock(editor); + if (!details) return; + + editor.undoManager.transact(() => { + let summary = details.querySelector('summary'); + if (!summary) { + summary = document.createElement('summary'); + details.prepend(summary); + } + summary.textContent = summaryContent; + }); +} + +/** + * @param {Editor} editor + */ +function detailsDialog(editor) { + return { + title: 'Edit collapsible block', + body: { + type: 'panel', + items: [ + { + type: 'input', + name: 'summary', + label: 'Toggle label', + }, + ], + }, + buttons: [ + { + type: 'cancel', + text: 'Cancel', + }, + { + type: 'submit', + text: 'Save', + primary: true, + }, + ], + onSubmit(api) { + const {summary} = api.getData(); + setSummary(editor, summary); + api.close(); + }, + }; +} + +/** + * @param {Element} element + */ +function getSummaryTextFromDetails(element) { + const summary = element.querySelector('summary'); + if (!summary) { + return ''; + } + return summary.textContent; +} + +/** + * @param {Editor} editor + */ +function showDetailLabelEditWindow(editor) { + const details = getSelectedDetailsBlock(editor); + const dialog = editor.windowManager.open(detailsDialog(editor)); + dialog.setData({summary: getSummaryTextFromDetails(details)}); +} + +/** + * @param {Editor} editor + */ +function unwrapDetailsInSelection(editor) { + const details = editor.selection.getNode().closest('details'); + const selectionBm = editor.selection.getBookmark(); + + if (details) { + const elements = details.querySelectorAll('details > *:not(summary, doc-root), doc-root > *'); + + editor.undoManager.transact(() => { + for (const element of elements) { + details.parentNode.insertBefore(element, details); + } + details.remove(); + }); + } + + editor.focus(); + editor.selection.moveToBookmark(selectionBm); +} + +/** + * @param {tinymce.html.Node} detailsEl + */ +function unwrapDetailsEditable(detailsEl) { + detailsEl.attr('contenteditable', null); + let madeUnwrap = false; + for (const child of detailsEl.children()) { + if (child.name === 'doc-root') { + child.unwrap(); + madeUnwrap = true; + } + } + + if (madeUnwrap) { + unwrapDetailsEditable(detailsEl); + } +} + +/** + * @param {tinymce.html.Node} detailsEl + */ +function ensureDetailsWrappedInEditable(detailsEl) { + unwrapDetailsEditable(detailsEl); + + detailsEl.attr('contenteditable', 'false'); + const rootWrap = window.tinymce.html.Node.create('doc-root', {contenteditable: 'true'}); + let previousBlockWrap = null; + + for (const child of detailsEl.children()) { + if (child.name === 'summary') continue; + const isBlock = blockElementTypes.includes(child.name); + + if (!isBlock) { + if (!previousBlockWrap) { + previousBlockWrap = window.tinymce.html.Node.create('p'); + rootWrap.append(previousBlockWrap); + } + previousBlockWrap.append(child); + } else { + rootWrap.append(child); + previousBlockWrap = null; + } + } + + detailsEl.append(rootWrap); +} + +/** + * @param {Editor} editor + */ +function setupElementFilters(editor) { + editor.parser.addNodeFilter('details', elms => { + for (const el of elms) { + ensureDetailsWrappedInEditable(el); + } + }); + + editor.serializer.addNodeFilter('details', elms => { + for (const el of elms) { + unwrapDetailsEditable(el); + el.attr('open', null); + } + }); + + editor.serializer.addNodeFilter('doc-root', elms => { + for (const el of elms) { + el.unwrap(); + } + }); +} + +/** + * @param {Editor} editor + */ +function register(editor) { editor.ui.registry.addIcon('details', ''); editor.ui.registry.addIcon('togglefold', ''); editor.ui.registry.addIcon('togglelabel', ''); @@ -89,178 +257,8 @@ function register(editor, url) { } /** - * @param {Editor} editor - */ -function showDetailLabelEditWindow(editor) { - const details = getSelectedDetailsBlock(editor); - const dialog = editor.windowManager.open(detailsDialog(editor)); - dialog.setData({summary: getSummaryTextFromDetails(details)}); -} - -/** - * @param {Editor} editor - */ -function getSelectedDetailsBlock(editor) { - return editor.selection.getNode().closest('details'); -} - -/** - * @param {Element} element - */ -function getSummaryTextFromDetails(element) { - const summary = element.querySelector('summary'); - if (!summary) { - return ''; - } - return summary.textContent; -} - -/** - * @param {Editor} editor - */ -function detailsDialog(editor) { - return { - title: 'Edit collapsible block', - body: { - type: 'panel', - items: [ - { - type: 'input', - name: 'summary', - label: 'Toggle label', - }, - ], - }, - buttons: [ - { - type: 'cancel', - text: 'Cancel', - }, - { - type: 'submit', - text: 'Save', - primary: true, - }, - ], - onSubmit(api) { - const {summary} = api.getData(); - setSummary(editor, summary); - api.close(); - }, - }; -} - -function setSummary(editor, summaryContent) { - const details = getSelectedDetailsBlock(editor); - if (!details) return; - - editor.undoManager.transact(() => { - let summary = details.querySelector('summary'); - if (!summary) { - summary = document.createElement('summary'); - details.prepend(summary); - } - summary.textContent = summaryContent; - }); -} - -/** - * @param {Editor} editor - */ -function unwrapDetailsInSelection(editor) { - const details = editor.selection.getNode().closest('details'); - const selectionBm = editor.selection.getBookmark(); - - if (details) { - const elements = details.querySelectorAll('details > *:not(summary, doc-root), doc-root > *'); - - editor.undoManager.transact(() => { - for (const element of elements) { - details.parentNode.insertBefore(element, details); - } - details.remove(); - }); - } - - editor.focus(); - editor.selection.moveToBookmark(selectionBm); -} - -/** - * @param {Editor} editor - */ -function setupElementFilters(editor) { - editor.parser.addNodeFilter('details', elms => { - for (const el of elms) { - ensureDetailsWrappedInEditable(el); - } - }); - - editor.serializer.addNodeFilter('details', elms => { - for (const el of elms) { - unwrapDetailsEditable(el); - el.attr('open', null); - } - }); - - editor.serializer.addNodeFilter('doc-root', elms => { - for (const el of elms) { - el.unwrap(); - } - }); -} - -/** - * @param {tinymce.html.Node} detailsEl - */ -function ensureDetailsWrappedInEditable(detailsEl) { - unwrapDetailsEditable(detailsEl); - - detailsEl.attr('contenteditable', 'false'); - const rootWrap = tinymce.html.Node.create('doc-root', {contenteditable: 'true'}); - let previousBlockWrap = null; - - for (const child of detailsEl.children()) { - if (child.name === 'summary') continue; - const isBlock = blockElementTypes.includes(child.name); - - if (!isBlock) { - if (!previousBlockWrap) { - previousBlockWrap = tinymce.html.Node.create('p'); - rootWrap.append(previousBlockWrap); - } - previousBlockWrap.append(child); - } else { - rootWrap.append(child); - previousBlockWrap = null; - } - } - - detailsEl.append(rootWrap); -} - -/** - * @param {tinymce.html.Node} detailsEl - */ -function unwrapDetailsEditable(detailsEl) { - detailsEl.attr('contenteditable', null); - let madeUnwrap = false; - for (const child of detailsEl.children()) { - if (child.name === 'doc-root') { - child.unwrap(); - madeUnwrap = true; - } - } - - if (madeUnwrap) { - unwrapDetailsEditable(detailsEl); - } -} - -/** - * @param {WysiwygConfigOptions} options * @return {register} */ -export function getPlugin(options) { +export function getPlugin() { return register; } diff --git a/resources/js/wysiwyg/plugins-imagemanager.js b/resources/js/wysiwyg/plugins-imagemanager.js index e7dd126cb..37b5bfafd 100644 --- a/resources/js/wysiwyg/plugins-imagemanager.js +++ b/resources/js/wysiwyg/plugins-imagemanager.js @@ -1,8 +1,7 @@ /** * @param {Editor} editor - * @param {String} url */ -function register(editor, url) { +function register(editor) { // Custom Image picker button editor.ui.registry.addButton('imagemanager-insert', { title: 'Insert image', @@ -23,9 +22,8 @@ function register(editor, url) { } /** - * @param {WysiwygConfigOptions} options * @return {register} */ -export function getPlugin(options) { +export function getPlugin() { return register; } diff --git a/resources/js/wysiwyg/plugins-tasklist.js b/resources/js/wysiwyg/plugins-tasklist.js index cf69287f1..191f83649 100644 --- a/resources/js/wysiwyg/plugins-tasklist.js +++ b/resources/js/wysiwyg/plugins-tasklist.js @@ -1,96 +1,3 @@ -/** - * @param {Editor} editor - * @param {String} url - */ -function register(editor, url) { - // Tasklist UI buttons - editor.ui.registry.addIcon('tasklist', ''); - editor.ui.registry.addToggleButton('tasklist', { - tooltip: 'Task list', - icon: 'tasklist', - active: false, - onAction(api) { - if (api.isActive()) { - editor.execCommand('RemoveList'); - } else { - editor.execCommand('InsertUnorderedList', null, { - 'list-item-attributes': { - class: 'task-list-item', - }, - 'list-style-type': 'tasklist', - }); - } - }, - onSetup(api) { - editor.on('NodeChange', event => { - const parentListEl = event.parents.find(el => el.nodeName === 'LI'); - const inList = parentListEl && parentListEl.classList.contains('task-list-item'); - api.setActive(Boolean(inList)); - }); - }, - }); - - // Tweak existing bullet list button active state to not be active - // when we're in a task list. - const existingBullListButton = editor.ui.registry.getAll().buttons.bullist; - existingBullListButton.onSetup = function(api) { - editor.on('NodeChange', event => { - const parentList = event.parents.find(el => el.nodeName === 'LI'); - const inTaskList = parentList && parentList.classList.contains('task-list-item'); - const inUlList = parentList && parentList.parentNode.nodeName === 'UL'; - api.setActive(Boolean(inUlList && !inTaskList)); - }); - }; - existingBullListButton.onAction = function() { - // Cheeky hack to prevent list toggle action treating tasklists as normal - // unordered lists which would unwrap the list on toggle from tasklist to bullet list. - // Instead we quickly jump through an ordered list first if we're within a tasklist. - if (elementWithinTaskList(editor.selection.getNode())) { - editor.execCommand('InsertOrderedList', null, { - 'list-item-attributes': {class: null}, - }); - } - - editor.execCommand('InsertUnorderedList', null, { - 'list-item-attributes': {class: null}, - }); - }; - // Tweak existing number list to not allow classes on child items - const existingNumListButton = editor.ui.registry.getAll().buttons.numlist; - existingNumListButton.onAction = function() { - editor.execCommand('InsertOrderedList', null, { - 'list-item-attributes': {class: null}, - }); - }; - - // Setup filters on pre-init - editor.on('PreInit', () => { - editor.parser.addNodeFilter('li', nodes => { - for (const node of nodes) { - if (node.attributes.map.class === 'task-list-item') { - parseTaskListNode(node); - } - } - }); - editor.serializer.addNodeFilter('li', nodes => { - for (const node of nodes) { - if (node.attributes.map.class === 'task-list-item') { - serializeTaskListNode(node); - } - } - }); - }); - - // Handle checkbox click in editor - editor.on('click', event => { - const clickedEl = event.target; - if (clickedEl.nodeName === 'LI' && clickedEl.classList.contains('task-list-item')) { - handleTaskListItemClick(event, clickedEl, editor); - event.preventDefault(); - } - }); -} - /** * @param {Element} element * @return {boolean} @@ -108,9 +15,9 @@ function elementWithinTaskList(element) { function handleTaskListItemClick(event, clickedEl, editor) { const bounds = clickedEl.getBoundingClientRect(); const withinBounds = event.clientX <= bounds.right - && event.clientX >= bounds.left - && event.clientY >= bounds.top - && event.clientY <= bounds.bottom; + && event.clientX >= bounds.left + && event.clientY >= bounds.top + && event.clientY <= bounds.bottom; // Outside of the task list item bounds mean we're probably clicking the pseudo-element. if (!withinBounds) { @@ -156,15 +63,111 @@ function serializeTaskListNode(node) { } // Create & insert checkbox input element - const checkbox = tinymce.html.Node.create('input', inputAttrs); + const checkbox = window.tinymce.html.Node.create('input', inputAttrs); checkbox.shortEnded = true; - node.firstChild ? node.insert(checkbox, node.firstChild, true) : node.append(checkbox); + + if (node.firstChild) { + node.insert(checkbox, node.firstChild, true); + } else { + node.append(checkbox); + } +} + +/** + * @param {Editor} editor + */ +function register(editor) { + // Tasklist UI buttons + editor.ui.registry.addIcon('tasklist', ''); + editor.ui.registry.addToggleButton('tasklist', { + tooltip: 'Task list', + icon: 'tasklist', + active: false, + onAction(api) { + if (api.isActive()) { + editor.execCommand('RemoveList'); + } else { + editor.execCommand('InsertUnorderedList', null, { + 'list-item-attributes': { + class: 'task-list-item', + }, + 'list-style-type': 'tasklist', + }); + } + }, + onSetup(api) { + editor.on('NodeChange', event => { + const parentListEl = event.parents.find(el => el.nodeName === 'LI'); + const inList = parentListEl && parentListEl.classList.contains('task-list-item'); + api.setActive(Boolean(inList)); + }); + }, + }); + + // Tweak existing bullet list button active state to not be active + // when we're in a task list. + const existingBullListButton = editor.ui.registry.getAll().buttons.bullist; + existingBullListButton.onSetup = function customBullListOnSetup(api) { + editor.on('NodeChange', event => { + const parentList = event.parents.find(el => el.nodeName === 'LI'); + const inTaskList = parentList && parentList.classList.contains('task-list-item'); + const inUlList = parentList && parentList.parentNode.nodeName === 'UL'; + api.setActive(Boolean(inUlList && !inTaskList)); + }); + }; + existingBullListButton.onAction = function customBullListOnAction() { + // Cheeky hack to prevent list toggle action treating tasklists as normal + // unordered lists which would unwrap the list on toggle from tasklist to bullet list. + // Instead we quickly jump through an ordered list first if we're within a tasklist. + if (elementWithinTaskList(editor.selection.getNode())) { + editor.execCommand('InsertOrderedList', null, { + 'list-item-attributes': {class: null}, + }); + } + + editor.execCommand('InsertUnorderedList', null, { + 'list-item-attributes': {class: null}, + }); + }; + // Tweak existing number list to not allow classes on child items + const existingNumListButton = editor.ui.registry.getAll().buttons.numlist; + existingNumListButton.onAction = function customNumListButtonOnAction() { + editor.execCommand('InsertOrderedList', null, { + 'list-item-attributes': {class: null}, + }); + }; + + // Setup filters on pre-init + editor.on('PreInit', () => { + editor.parser.addNodeFilter('li', nodes => { + for (const node of nodes) { + if (node.attributes.map.class === 'task-list-item') { + parseTaskListNode(node); + } + } + }); + editor.serializer.addNodeFilter('li', nodes => { + for (const node of nodes) { + if (node.attributes.map.class === 'task-list-item') { + serializeTaskListNode(node); + } + } + }); + }); + + // Handle checkbox click in editor + editor.on('click', event => { + const clickedEl = event.target; + if (clickedEl.nodeName === 'LI' && clickedEl.classList.contains('task-list-item')) { + handleTaskListItemClick(event, clickedEl, editor); + event.preventDefault(); + } + }); } /** - * @param {WysiwygConfigOptions} options * @return {register} */ -export function getPlugin(options) { +export function getPlugin() { return register; } diff --git a/resources/js/wysiwyg/scrolling.js b/resources/js/wysiwyg/scrolling.js index faeb837a4..92f8f1583 100644 --- a/resources/js/wysiwyg/scrolling.js +++ b/resources/js/wysiwyg/scrolling.js @@ -1,16 +1,3 @@ -/** - * Scroll to a section dictated by the current URL query string, if present. - * Used when directly editing a specific section of the page. - * @param {Editor} editor - */ -export function scrollToQueryString(editor) { - const queryParams = (new URL(window.location)).searchParams; - const scrollId = queryParams.get('content-id'); - if (scrollId) { - scrollToText(editor, scrollId); - } -} - /** * @param {Editor} editor * @param {String} scrollId @@ -27,3 +14,16 @@ function scrollToText(editor, scrollId) { editor.selection.collapse(false); editor.focus(); } + +/** + * Scroll to a section dictated by the current URL query string, if present. + * Used when directly editing a specific section of the page. + * @param {Editor} editor + */ +export function scrollToQueryString(editor) { + const queryParams = (new URL(window.location)).searchParams; + const scrollId = queryParams.get('content-id'); + if (scrollId) { + scrollToText(editor, scrollId); + } +} diff --git a/resources/js/wysiwyg/shortcuts.js b/resources/js/wysiwyg/shortcuts.js index b624b23a2..1c20df9c5 100644 --- a/resources/js/wysiwyg/shortcuts.js +++ b/resources/js/wysiwyg/shortcuts.js @@ -35,7 +35,9 @@ export function register(editor) { const callout = selectedNode ? selectedNode.closest('.callout') : null; const formats = ['info', 'success', 'warning', 'danger']; - const currentFormatIndex = formats.findIndex(format => callout && callout.classList.contains(format)); + const currentFormatIndex = formats.findIndex(format => { + return callout && callout.classList.contains(format); + }); const newFormatIndex = (currentFormatIndex + 1) % formats.length; const newFormat = formats[newFormatIndex]; diff --git a/resources/js/wysiwyg/toolbars.js b/resources/js/wysiwyg/toolbars.js index 9acb24c1d..4663ad132 100644 --- a/resources/js/wysiwyg/toolbars.js +++ b/resources/js/wysiwyg/toolbars.js @@ -70,9 +70,8 @@ function registerImageContextToolbar(editor) { /** * @param {Editor} editor - * @param {WysiwygConfigOptions} options */ -export function registerAdditionalToolbars(editor, options) { +export function registerAdditionalToolbars(editor) { registerPrimaryToolbarGroups(editor); registerLinkContextToolbar(editor); registerImageContextToolbar(editor);