/** * 3D Foundation Project * Copyright 2025 Smithsonian Institution * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* Import TinyMCE */ import tinymce from 'tinymce'; /* Default icons are required for TinyMCE 5.3 or above */ import 'tinymce/icons/default'; /* A theme is also required */ import 'tinymce/themes/silver'; import 'tinymce/models/dom/model'; /* Import the skin */ import './editor_css/skin.min.css'; /* Import plugins */ import 'tinymce/plugins/link'; import 'tinymce/plugins/lists'; import 'tinymce/plugins/image'; import 'tinymce/plugins/media'; /* Import content css */ import contentUiCss from './editor_css/content.ui.min.css?raw'; import contentCss from './editor_css/content.min.css?raw'; import contentOverrides from './editor_css/overrides.css?raw'; import Notification from "@ff/ui/Notification"; import MessageBox from "@ff/ui/MessageBox"; import SystemView, { customElement } from "@ff/scene/ui/SystemView"; import CVAssetManager from "../../components/CVAssetManager"; import CVAssetReader from "../../components/CVAssetReader"; import CVAssetWriter from "../../components/CVAssetWriter"; import CVMediaManager, { IAssetOpenEvent, IAssetRenameEvent } from "../../components/CVMediaManager"; import CVStandaloneFileManager from "../../components/CVStandaloneFileManager"; import CVReader from "../../components/CVReader"; //////////////////////////////////////////////////////////////////////////////// @customElement("sv-article-editor") export default class ArticleEditor extends SystemView { private _container: HTMLDivElement = null; private _overlay: HTMLElement = null; private _assetPath: string = ""; protected get mediaManager() { return this.system.getMainComponent(CVMediaManager); } protected get assetManager() { return this.system.getMainComponent(CVAssetManager); } protected get assetReader() { return this.system.getMainComponent(CVAssetReader); } protected get assetWriter() { return this.system.getMainComponent(CVAssetWriter); } protected get standaloneFileManager() { return this.system.getMainComponent(CVStandaloneFileManager, true); } protected get articleReader() { return this.system.getComponent(CVReader); } openArticle(assetPath: string) { if (this._assetPath) { return this.closeArticle().then(() => this.readArticle(assetPath)); } return this.readArticle(assetPath); } saveArticle() { if (this._assetPath) { this.writeArticle(); } } closeArticle() { if (tinymce.activeEditor.isDirty() && this._assetPath) { return MessageBox.show("Close Article", "Would you like save your changes?", "warning", "yes-no").then(result => { if (result.ok) { return this.writeArticle().then(() => this.clearArticle()); } else { return this.clearArticle(); } }); } return this.clearArticle(); } protected readArticle(assetPath: string) { return this.assetReader.getText(assetPath) .then(content => this.parseArticle(content, assetPath)) .catch(e=>{ return `

Article not found at ${assetPath}

${e}

`; }) .then(content => { //this._editor.root.innerHTML = content; tinymce.activeEditor.setContent(content, {format: "raw"}); this._assetPath = assetPath; }).then(() => { tinymce.activeEditor.setDirty(false); if(this._overlay.parentElement === this) { this.removeChild(this._overlay); } }); } protected parseArticle(content: string, articlePath: string): Promise { // remove line breaks content = content.replace(/[\n\r]/g, ""); // transform article-relative to absolute URLs const articleBasePath = this.assetManager.getAssetBasePath(articlePath); content = content.replace(/(src=\")(.*?)(\")/g, (match, pre, assetUrl, post) => { if (!assetUrl.startsWith("/") && !assetUrl.startsWith("http")) { assetUrl = this.assetManager.getAssetUrl(articleBasePath + assetUrl); } return pre + assetUrl + post; }); return Promise.resolve(content); } protected writeArticle() { const basePath = this.assetManager.getAssetBasePath(this._assetPath); let content = tinymce.activeEditor.getContent({format: "raw"}); //this._editor.root.innerHTML; // transform absolute to article-relative URLs content = content.replace(/(src=\")(.*?)(\")/g, (match, pre, assetUrl, post) => { if((assetUrl as string).startsWith("blob")) { assetUrl = this.standaloneFileManager.blobUrlToFileUrl(assetUrl); assetUrl = assetUrl.replace(CVMediaManager.articleFolder + "/", ''); return pre + assetUrl + post; } return pre + this.assetManager.getRelativeAssetPath(assetUrl, basePath) + post; }); return this.assetWriter.putText(content, this._assetPath) .then(() => { tinymce.activeEditor.setDirty(false); this.articleReader.ins.articleId.set(); new Notification(`Article successfully written to '${this._assetPath}'`, "info"); }) .catch(error => { new Notification(`Failed to write article to '${this._assetPath}': ${error.message}`, "error"); }); } protected clearArticle() { tinymce.activeEditor.setContent(""); this._assetPath = ""; tinymce.activeEditor.setDirty(false); this.appendChild(this._overlay); return Promise.resolve(); } protected firstConnected() { super.firstConnected(); this.classList.add("sv-article-editor"); this._container = this.appendElement("div"); this._container.classList.add("sv-container"); this._container.id = "editor_wrapper" this._overlay = this.appendElement("div"); this._overlay.classList.add("sv-overlay"); } protected connected() { super.connected(); this.mediaManager.on("asset-open", this.onOpenAsset, this); this.mediaManager.on("asset-rename", this.onRenameAsset, this); this._container.id = "editor_wrapper" tinymce.init({ selector: "#editor_wrapper", plugins: "image link lists media", toolbar: 'saveButton closeButton | undo redo | link image media | bold italic | alignleft aligncenter alignright alignjustify | bullist numlist | outdent indent | styles', menubar: false, skin: false, height: "100%", resize: false, branding: false, automatic_uploads: true, images_reuse_filename: true, link_assume_external_targets: 'https', paste_as_text: true, content_css: false, font_css: this.assetReader.getSystemAssetUrl("fonts/fonts.css"), content_style: [contentCss, contentUiCss, contentOverrides].join('\n'), convert_urls: false, image_caption: true, link_default_target: '_blank', //link_target_list: false, images_upload_handler: (file, progress) => new Promise((resolve, reject) => { const filename = this.mediaManager.getUniqueName(CVMediaManager.articleFolder + "/" + file.filename()); this.mediaManager.uploadFile(filename, file.blob(), this.mediaManager.getAssetByPath(CVMediaManager.articleFolder + "/")). then( () => { resolve(this.assetManager.getAssetUrl(CVMediaManager.articleFolder + "/" + filename))}); }), init_instance_callback: (editor) => { editor.editorUpload.addFilter((img) => { const blobInfo = editor.editorUpload.blobCache.getByUri(img.src); if(blobInfo) { if(this.standaloneFileManager) { const filename = this.mediaManager.getUniqueName(CVMediaManager.articleFolder + "/" + blobInfo.filename()); this.mediaManager.uploadFile(filename, blobInfo.blob(), this.mediaManager.getAssetByPath(CVMediaManager.articleFolder + "/")) img.src = this.assetManager.getAssetUrl(CVMediaManager.articleFolder + "/" + filename); } else { return true; } } return false; }); }, setup: (editor) => { editor.ui.registry.addButton('saveButton', { text: 'Save', icon: 'save', onAction: (_) => { this.saveArticle(); } }); editor.ui.registry.addButton('closeButton', { text: 'Close', icon: 'close', onAction: (_) => { this.closeArticle(); } }); /*editor.on('init', function(args) { editor = args.target; editor.on('NodeChange', function(e) { if (e && e.element.nodeName.toLowerCase() == 'img') { tinymce.DOM.setAttribs(e.element, {'height': "100%", 'max-width': "100%"}); } }); });*/ }, }); } protected disconnected() { this.mediaManager.off("asset-rename", this.onRenameAsset, this); this.mediaManager.off("asset-open", this.onOpenAsset, this); tinymce.activeEditor.remove(); super.disconnected(); } protected onOpenAsset(event: IAssetOpenEvent) { // if there is no asset, close any current article if(event.asset === null ) { if(this._assetPath) { this.closeArticle(); } } // if opened asset is of type text/*, open it in the editor else if (event.asset.info.type.startsWith("text/")) { this.openArticle(event.asset.info.path); } } protected onRenameAsset(event: IAssetRenameEvent) { // update asset path if(event.oldPath === this._assetPath ) { this._assetPath = event.newPath; } } }