diff --git a/resources/js/components/SMEditor.vue b/resources/js/components/SMEditor.vue index 64efb53..d141fb1 100644 --- a/resources/js/components/SMEditor.vue +++ b/resources/js/components/SMEditor.vue @@ -1,12 +1,14 @@ @@ -48,13 +50,17 @@ import "tinymce/plugins/pagebreak"; import "tinymce/plugins/nonbreaking"; import "tinymce/plugins/emoticons"; import "tinymce/plugins/autosave"; +import "tinymce/plugins/wordcount"; -import { ref, watch, computed, onUnmounted } from "vue"; -import { arrayHasBasicMatch } from "../helpers/array"; - -import DialogMedia from "./dialogs/SMDialogMedia.vue"; -import { openDialog } from "vue3-promise-dialog"; +import { ref, watch, computed } from "vue"; import { routes } from "../router"; +import { api } from "../helpers/api"; +import { MediaCollection, MediaResponse } from "../helpers/api.types"; + +interface PageList { + title: string; + value: string; +} const props = defineProps({ disabledEditor: { @@ -107,12 +113,22 @@ const props = defineProps({ }, }); +const useDarkMode = false; // window.matchMedia("(prefers-color-scheme: dark)").matches; +const tinyeditor = ref(null); + const init = { promotion: false, - // emoticons_database_url: "/tinymce/plugins/emoticons/js/emojis.min.js", - skin_url: "/tinymce/skins/ui/oxide", - content_css: "/tinymce/skins/content/default/content.min.css", - height: 500, + emoticons_database_url: "/tinymce/plugins/emoticons/js/emojis.min.js", + template_cdate_format: "[Date Created (CDATE): %m/%d/%Y : %H:%M:%S]", + template_mdate_format: "[Date Modified (MDATE): %m/%d/%Y : %H:%M:%S]", + relative_urls: false, + skin_url: useDarkMode + ? "/tinymce/skins/ui/oxide-dark" + : "/tinymce/skins/ui/oxide", + content_css: useDarkMode + ? "/tinymce/skins/content/default/dark.min.css" + : "/tinymce/skins/content/default/content.min.css", + height: 600, plugins: [ "link", "autolink", @@ -124,70 +140,64 @@ const init = { "searchreplace", "visualblocks", "code", - "fullscreen", - "preview", "anchor", "insertdatetime", "media", "help", "codesample", - "pagebreak", "nonbreaking", "importcss", "directionality", "visualchars", - // "emoticons", + "emoticons", "autosave", + "searchreplace", ], toolbar: - "undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft aligncenter alignright alignjustify | outdent indent | numlist bullist | forecolor backcolor removeformat | pagebreak | charmap emoticons | fullscreen preview save print | insertfile image media template link anchor codesample | ltr rtl", + "h1 h2 h3 blockquote | bold italic underline strikethrough | numlist bullist | image media link anchor codesample | alignleft aligncenter alignright alignjustify | forecolor backcolor removeformat | outdent indent | charmap emoticons | undo redo", branding: false, menubar: false, - toolbar_mode: "wrap", - link_list: [ - { title: "Tiny Home Page", value: "https://www.tiny.cloud" }, - { title: "Tiny Blog", value: "https://www.tiny.cloud/blog" }, - { - title: "TinyMCE Documentation", - value: "https://www.tiny.cloud/docs/", - }, - { - title: "TinyMCE on Stack Overflow", - value: "https://stackoverflow.com/questions/tagged/tinymce", - }, - { title: "TinyMCE GitHub", value: "https://github.com/tinymce/" }, + toolbar_mode: "sliding", + autosave_ask_before_unload: true, + autosave_interval: "30s", + autosave_prefix: "{path}{query}-{id}-", + autosave_restore_when_empty: false, + autosave_retention: "2m", + image_advtab: true, + codesample_global_prismjs: true, + codesample_languages: [ + { text: "Bash", value: "bash" }, + { text: "C", value: "c" }, + { text: "C++", value: "cpp" }, + { text: "C#", value: "csharp" }, + { text: "CSS", value: "css" }, + { text: "HTML/XML", value: "markup" }, + { text: "Java", value: "java" }, + { text: "JavaScript", value: "javascript" }, + { text: "Objective-C", value: "objectivec" }, + { text: "Perl", value: "perl" }, + { text: "PHP", value: "php" }, + { text: "Python", value: "python" }, + { text: "Regex", value: "regex" }, + { text: "Ruby", value: "ruby" }, + { text: "SQL", value: "sql" }, + { text: "Swift", value: "swift" }, + { text: "YAML", value: "yml" }, ], - // https://www.tiny.cloud/docs/configure/file-image-upload/#images_upload_handler - images_upload_handler: (blobInfo, success, failure) => { - console.log(blobInfo); - console.log(success); - console.log(failure); - - const img = "data:image/jpeg;base64," + blobInfo.base64(); - console.log(img); - success(img); + link_title: false, + link_list: (success) => { + const links = fetchLinkList(); + success(links); + }, + file_picker_callback: function (callback, value, meta) { + imageBrowser(callback, value, meta); }, }; -const trix = ref(null); const editorContent = ref(props.srcContent); -const isActive = ref(null); -const isInitalized = ref(false); -const initalizeQueue = ref([]); +const isActive = ref(false); -const emits = defineEmits([ - "input", - "update", - "update:srcContent", - "trix-file-accept", - "trix-attachment-add", - "trix-attachment-remove", - "trix-selection-change", - "trix-initialize", - "trix-before-initialize", - "trix-focus", - "trix-blur", -]); +const emits = defineEmits(["input", "update", "blur"]); const handleContentChange = (event) => { editorContent.value = event.srcElement @@ -196,136 +206,16 @@ const handleContentChange = (event) => { emits("input", editorContent.value); }; -const handleInitialize = () => { - isInitalized.value = true; - - if (props.removeButtons) { - props.removeButtons.forEach((b) => { - trix.value.toolbarElement - .querySelectorAll(`[data-trix-attribute="${b}"]`) - .forEach((e) => e.remove()); - trix.value.toolbarElement - .querySelectorAll(`[data-trix-action="${b}"]`) - .forEach((e) => e.remove()); - }); - } - - // if(!props.allowMedia) { - // trix.value.toolbarElement.querySelectorAll('[data-trix-action="attachFiles"]').forEach(e => e.remove()) - // } - - initalizeQueue.value.forEach((item) => item()); - - decorateDisabledEditor(props.disabledEditor); - emits("trix-initialize"); -}; - -const handleInitialContentChange = (newContent, oldContent) => { +const handleInitialContentChange = (newContent) => { newContent = newContent === undefined ? "" : newContent; - - // if (trix.value && trix.value.innerHTML !== newContent) { editorContent.value = newContent; - // } - - // if (!isActive.value) { - // reloadEditorContent(editorContent.value); - // } }; -const emitEditorState = (value) => { - emits("update", editorContent.value); - emits("update:srcContent", editorContent.value); -}; - -const emitFileAccept = (event) => { - if (props.mimeTypes) { - if (!arrayHasBasicMatch(props.mimeTypes, event.file.type)) { - window.alert("That file type is not supported"); - event.preventDefault(); - return; - } - } - - emits("trix-file-accept", event); -}; - -const emitAttachmentAdd = (event) => { - emits("trix-attachment-add", event); -}; - -const emitAttachmentRemove = (event) => { - emits("trix-attachment-remove", event); -}; - -const emitSelectionChange = (event) => { - emits("trix-selection-change", trix.value.editor, event); -}; - -const emitBeforeInitialize = async (event) => { - whenInitalized(() => { - emits("trix-before-initialize", trix.value.editor, event); - }); -}; - -const processTrixFocus = (event) => { - isActive.value = true; - emits("trix-focus", trix.value.editor, event); -}; - -const processTrixBlur = (event) => { +const handleBlur = (event, editor) => { isActive.value = false; - emits("trix-blur", trix.value.editor, event); + console.log("blur", editorContent.value, editor); + emits("blur", event); }; -const whenInitalized = (func) => { - if (isInitalized.value) { - func(); - } else { - initalizeQueue.value.push(func); - } -}; - -const reloadEditorContent = async (newContent) => { - whenInitalized(() => { - // trix.value.editor.loadHTML(newContent); - // trix.value.editor.setSelectedRange(getContentEndPosition()); - // console.log(Trix.config); - // console.log(trix.value.toolbarElement); - }); -}; - -const decorateDisabledEditor = async (editorState) => { - whenInitalized(() => { - if (editorState) { - trix.value.toolbarElement.style["pointer-events"] = "none"; - trix.value.contentEditable = false; - trix.value.style["background"] = "#e9ecef"; - } else { - trix.value.toolbarElement.style["pointer-events"] = "unset"; - trix.value.style["pointer-events"] = "unset"; - trix.value.style["background"] = "#ffffff"; - } - }); -}; - -const getContentEndPosition = () => { - return trix.value.editor.getDocument().toString().length - 1; -}; - -const randomId = () => { - return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { - var r = (Math.random() * 16) | 0; - var v = c === "x" ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); -}; - -const generatedId = computed(() => { - return randomId(); -}); - -const computedId = computed(() => { - return props.inputId || generatedId.value; -}); const initialContent = computed(() => { return props.srcContent; @@ -335,481 +225,357 @@ const isDisabled = computed(() => { return props.disabledEditor; }); -watch(editorContent, emitEditorState); watch(initialContent, handleInitialContentChange); -watch(isDisabled, decorateDisabledEditor); -/** Extra Toolbar Buttons */ +const fetchLinkList = () => { + const buildPageList = ( + pageList, + routeEntries, + prefix_url = "", + prefix_title = "" + ) => { + routeEntries.forEach((entry) => { + if ( + "path" in entry && + entry.path.includes(":") == false && + "meta" in entry && + "title" in entry.meta && + ("hideInEditor" in entry.meta == false || + entry.meta.hideInEditor == false) && + ("middleware" in entry.meta == false || + ("showInEditor" in entry.meta == true && + entry.meta.showInEditor == true)) + ) { + const sep = entry.path.substring(0, 1) == "/" ? "" : "/"; + pageList[prefix_url + sep + entry.path] = + prefix_title.length > 0 + ? `${prefix_title} ${sep} ${entry.meta.title}` + : entry.meta.title.toLowerCase() == "home" + ? entry.meta.title + : `Home / ${entry.meta.title}`; + } -const addToolbarButton = async (name, options, func) => { - if (props.removeButtons && props.removeButtons.includes(name)) { - return; - } - - whenInitalized(() => { - options.type = options.type || "attribute"; - options.icon = options.icon || "?"; - options.group = options.group || "text"; - options.position = options.position || "beforeend"; - options.id = options.id || randomId(); - options.attribute = options.attribute || name; - - if ( - options.trixAttribute && - options.trixAttribute.type && - options.trixAttribute.data && - Trix.config[options.trixAttribute.type + "Attributes"] - ) { - Trix.config[options.trixAttribute.type + "Attributes"][name] = - options.trixAttribute.data; - } - - if (options.html) { - options.html = options.html.replace(/%id%/gi, options.id); - if (func) { - options.html = options.html.replace( - /%func\((.*?)\)%/gi, - `Trix.$extensions.${name}(event, '${name}', '${options.id}', $1)` + if ("children" in entry) { + buildPageList( + pageList, + entry.children, + prefix_url + entry.path, + prefix_title + (entry.meta?.title || "") ); } - } + }); + }; - if (func) { - if (Trix.$extensions === undefined) Trix.$extensions = {}; - Trix.$extensions[name] = func; - } + let pageRoutes: { [key: string]: string } = {}; + buildPageList(pageRoutes, routes); - trix.value.toolbarElement - .querySelector( - `.trix-button-group.trix-button-group--${options.group}-tools` - ) - .insertAdjacentHTML( - options.position, - `${ - options.divWrap - ? '
' - : "" - }${options.html ? `${options.html}` : ""}${ - options.divWrap ? "
" : "" - }` - ); - - if (options.type == "attribute" && options.dialog) { - trix.value.toolbarElement - .querySelector(`.trix-dialogs`) - .insertAdjacentHTML( - "beforeend", - `
${options.dialog}
` - ); - } - }); -}; - -/* Foreground and Background Colors - Based on https://github.com/basecamp/trix/issues/985 */ -const foregroundColor = { - icon: '', - group: "text", - position: "beforeend", - title: "Text colour", - html: '', - divWrap: true, - trixAttribute: { - type: "text", - data: { - styleProperty: "color", - inheritable: true, - }, - }, -}; - -const backgroundColor = { - icon: '', - group: "text", - position: "beforeend", - title: "Background colour", - html: '', - divWrap: true, - trixAttribute: { - type: "text", - data: { - styleProperty: "backgroundColor", - inheritable: true, - }, - }, -}; - -const fgBgColorFunc = (event, name, id, data) => { - var picker = document.getElementById(id + "-picker"); - - if (data == "colorChanged") { - trix.value.editor.activateAttribute(name, picker.value); + const pageList: PageList[] = []; + for (const [key, value] of Object.entries(pageRoutes)) { + pageList.push({ title: value, value: key }); } -}; -addToolbarButton("foreground", foregroundColor, fgBgColorFunc); -addToolbarButton("background", backgroundColor, fgBgColorFunc); + pageList.sort((a, b) => { + const titleA = a.title.toLowerCase(); + const titleB = b.title.toLowerCase(); -/* Text align center button - No function needed for this button */ -addToolbarButton("textAlignCenter", { - icon: '', - group: "block", - position: "beforeend", - title: "Align text center", - trixAttribute: { - type: "block", - data: { - tagName: "centered", - }, - }, -}); - -/* Remove all formatting button */ -addToolbarButton( - "removeFormatting", - { - type: "action", - icon: '', - group: "text", - position: "beforeend", - title: "Remove formatting", - }, - (event, name, id, data) => { - // let removeAttrs = ['bold', 'italic', 'strike', 'href', 'foreground', 'background', 'bullet'] - // removeAttrs.forEach(attr => trix.value.editor.deactivateAttribute(attr)) - - Object.keys(Trix.config.textAttributes) - .concat(Object.keys(Trix.config.blockAttributes)) - .forEach((attr) => trix.value.editor.deactivateAttribute(attr)); - } -); - -/** Media Selector */ -addToolbarButton( - "media", - { - type: "action", - icon: '', - group: "file", - title: "Insert Media", - }, - async (event, name, id, data) => { - let result = await openDialog(DialogMedia); - - if (result.url) { - trix.value.editor.insertHTML(``); - } - } -); - -/* Update Link Button */ -let hrefDialogObserver = null; -let hrefDialogObserverTarget = null; - -onUnmounted(() => { - if (hrefDialogObserver != null && hrefDialogObserverTarget != null) { - hrefDialogObserver.unobserve(hrefDialogObserverTarget); - - hrefDialogObserver = null; - hrefDialogObserverTarget = null; - } -}); - -const buildPageList = ( - pageList, - routeEntries, - prefix_url = "", - prefix_title = "" -) => { - routeEntries.forEach((entry) => { - if ("path" in entry && "meta" in entry && "title" in entry.meta) { - const sep = entry.path.substring(0, 1) == "/" ? "" : "/"; - pageList[prefix_url + sep + entry.path] = - prefix_title + sep + entry.meta.title; + if (titleA < titleB) { + return -1; } - if ("children" in entry) { - buildPageList( - pageList, - entry.children, - prefix_url + entry.path, - prefix_title + (entry.meta?.title || "") - ); + if (titleA > titleB) { + return 1; } - }); -}; -/** - * - * @param obj - */ -function sortProperties(obj) { - // convert object into array - var sortable = []; - for (var key in obj) - if (obj.hasOwnProperty(key)) sortable.push([key, obj[key]]); // each item is an array in format [key, value] - - // sort items by value - sortable.sort(function (a, b) { - var x = a[1].toLowerCase(), - y = b[1].toLowerCase(); - return x < y ? -1 : x > y ? 1 : 0; + return 0; }); - obj = {}; - sortable.forEach((item) => { - obj[item[0]] = item[1]; - }); + return pageList; +}; - return obj; // array in format [ [ key1, val1 ], [ key2, val2 ], ... ] -} +const imageBrowser = (callback, value, meta) => { + var galleryPage = 1; + var galleryMax = 1; -whenInitalized(() => { - const hrefDialog = trix.value.toolbarElement.querySelector( - ".trix-dialogs .trix-dialog.trix-dialog--link" - ); + // Open a dialog to select a file + const input = document.createElement("input"); + input.setAttribute("type", "file"); + input.setAttribute("accept", "image/*"); + input.onchange = function () { + if (input.files) { + let formData = new FormData(); + formData.append("file", input.files[0]); - const hrefInput = hrefDialog.querySelector(".trix-input--dialog"); + api.post({ + url: "/media", + body: formData, + }) + .then((result) => { + input.value = ""; + const data = result.data as MediaResponse; - const handleHref = (event, name, id, data) => { - if (id == "select") { - document.getElementById("href-hidden").value = - import.meta.env.APP_URL + event.target.value; - document.getElementById("href-input").value = ""; - } else if (id == "input") { - document.getElementById("href-hidden").value = event.target.value; - document.getElementById("href-select").value = ""; - } else { - /* empty */ + if (data.medium) { + callback(data.medium.url); + dialog.close(); + } else { + alert("The server responded with an unknown error"); + } + }) + .catch((error) => { + input.value = ""; + alert( + error.data.message || + "An unexpected error occurred uploading the file to the server." + ); + }); } }; - hrefDialogObserverTarget = hrefDialog; - if (hrefDialogObserver == null && hrefDialogObserverTarget != null) { - hrefDialogObserver = new IntersectionObserver((entries, observer) => { - entries.forEach((entry) => { - if (entry.intersectionRatio === 1) { - const hidden = document.getElementById("href-hidden"); - if (hidden != null) { - if (hidden.value.startsWith(import.meta.env.APP_URL)) { - document.getElementById("href-select").value = - hidden.value.substring( - import.meta.env.APP_URL.length - ); - document.getElementById("href-input").value = ""; - } else { - document.getElementById("href-select").value = ""; - document.getElementById("href-input").value = - hidden.value; - } - } + // create the header element + const header = document.createElement("div"); + header.id = "tinymce-gallery-header"; + + // create the gallery element + const gallery = document.createElement("div"); + gallery.id = "tinymce-gallery"; + + const updateGallery = () => { + api.get({ + url: "/media", + params: { + limit: 12, + page: galleryPage, + }, + }) + .then((result) => { + const data = result.data as MediaCollection; + galleryMax = Math.ceil(data.total / 12); + + const infoElement = document.querySelector( + "#tinymce-gallery-header .info" + ); + if (infoElement != null) { + infoElement.innerHTML = `${galleryPage} / ${galleryMax}`; } + + const galleryContainer = + document.getElementById("tinymce-gallery"); + if (galleryContainer != null) { + // delete existing items + const divElements = + galleryContainer.querySelectorAll("div"); + divElements.forEach((div) => { + div.remove(); + }); + + // add new items + data.media.forEach((medium) => { + const img = document.createElement("div"); + img.classList.add("gallery-image"); + img.style.backgroundImage = `url('${medium.url}?w=200')`; + img.style.cursor = "pointer"; + img.onclick = function () { + console.log("click"); + callback(medium.url); + dialog.close(); + }; + + galleryContainer.appendChild(img); + }); + } + }) + .catch(() => { + /* empty */ }); - }).observe(hrefDialogObserverTarget); + }; + + // Add the container and file input to the dialog + const dialog = tinymce.activeEditor.windowManager.open({ + title: "Insert image", + size: "large", + body: { + type: "panel", + items: [ + { + type: "htmlpanel", + html: header.outerHTML, + }, + { + type: "htmlpanel", + html: gallery.outerHTML, + }, + ], + }, + buttons: [ + { + type: "custom", + text: "Upload", + name: "upload", + }, + { + type: "cancel", + text: "Cancel", + }, + ], + onAction: function (_dialogApi, details) { + if (details.name === "upload") { + input.click(); + } + }, + }); + + // create the child elements + const heading = document.createElement("div"); + heading.className = "heading"; + heading.textContent = "Select an image or upload a new one"; + + const pagination = document.createElement("div"); + pagination.className = "pagination"; + + const prevButton = document.createElement("button"); + prevButton.className = "prev"; + prevButton.addEventListener("click", () => { + console.log("prev"); + if (galleryPage > 1) { + galleryPage--; + updateGallery(); + } + }); + + const infoDiv = document.createElement("div"); + infoDiv.className = "info"; + infoDiv.textContent = `${galleryPage} / ${galleryMax}`; + + const nextButton = document.createElement("button"); + nextButton.className = "next"; + nextButton.addEventListener("click", () => { + console.log("next"); + if (galleryPage < galleryMax) { + galleryPage++; + updateGallery(); + } + // handle click on the next button + }); + + // add the child elements to the parent element + pagination.appendChild(prevButton); + pagination.appendChild(infoDiv); + pagination.appendChild(nextButton); + + const renderedHeader = document.getElementById("tinymce-gallery-header"); + if (renderedHeader) { + renderedHeader.appendChild(heading); + renderedHeader.appendChild(pagination); } - if (Trix.$extensions === undefined) Trix.$extensions = {}; - Trix.$extensions["href"] = handleHref; - - let pageRoutes = {}; - buildPageList(pageRoutes, routes); - - pageRoutes = sortProperties(pageRoutes); - - hrefInput.removeAttribute("required"); - hrefInput.setAttribute("id", "href-hidden"); - hrefInput.setAttribute( - "oninput", - "Trix.$extensions.href(event, 'href', 'href', 'change')" - ); - hrefInput.insertAdjacentHTML( - "afterend", - `
-
` - ); -}); + updateGallery(); +};