diff --git a/src/web/Manager.mjs b/src/web/Manager.mjs index 793b61de..730d6e2e 100755 --- a/src/web/Manager.mjs +++ b/src/web/Manager.mjs @@ -152,7 +152,6 @@ class Manager { this.addListeners("#input-wrapper", "dragover", this.input.inputDragover, this.input); this.addListeners("#input-wrapper", "dragleave", this.input.inputDragleave, this.input); this.addListeners("#input-wrapper", "drop", this.input.inputDrop, this.input); - document.querySelector("#input-file .close").addEventListener("click", this.input.clearIoClick.bind(this.input)); document.getElementById("btn-new-tab").addEventListener("click", this.input.addInputClick.bind(this.input)); document.getElementById("btn-previous-input-tab").addEventListener("mousedown", this.input.previousTabClick.bind(this.input)); document.getElementById("btn-next-input-tab").addEventListener("mousedown", this.input.nextTabClick.bind(this.input)); @@ -218,7 +217,6 @@ class Manager { this.addDynamicListener(".option-item select", "change", this.options.selectChange, this.options); document.getElementById("theme").addEventListener("change", this.options.themeChange.bind(this.options)); document.getElementById("logLevel").addEventListener("change", this.options.logLevelChange.bind(this.options)); - document.getElementById("imagePreview").addEventListener("change", this.input.renderFileThumb.bind(this.input)); // Misc window.addEventListener("keydown", this.bindings.parseInput.bind(this.bindings)); diff --git a/src/web/html/index.html b/src/web/html/index.html index 68d69a78..6e2c60a3 100755 --- a/src/web/html/index.html +++ b/src/web/html/index.html @@ -265,21 +265,6 @@
-
-
-
-
- -
- - Name:
- Size:
- Type:
- Loaded: -
-
-
-
diff --git a/src/web/stylesheets/layout/_io.css b/src/web/stylesheets/layout/_io.css index 185b3bdb..9c64fe85 100755 --- a/src/web/stylesheets/layout/_io.css +++ b/src/web/stylesheets/layout/_io.css @@ -220,7 +220,6 @@ transition: all 0.5s ease; } -#input-file, #output-file { position: absolute; left: 0; @@ -450,9 +449,10 @@ font-size: 12px !important; } -.ͼ2 .cm-panels { +.ͼ2 .cm-panels, +.ͼ2 .cm-side-panels { background-color: var(--secondary-background-colour); - border-color: var(--secondary-border-colour); + border-color: var(--primary-border-colour); color: var(--primary-font-colour); } @@ -547,4 +547,44 @@ text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; -} \ No newline at end of file +} + + +/* File details panel */ + +.cm-file-details { + text-align: center; + display: flex; + flex-direction: column; + align-items: center; + overflow-y: auto; + padding-bottom: 21px; + height: 100%; +} + +.file-details-heading { + font-weight: bold; + margin: 10px 0 10px 0; +} + +.file-details-data { + text-align: left; + margin: 10px 2px; +} + +.file-details-data td { + padding: 0 3px; + max-width: 130px; + min-width: 60px; + overflow: hidden; + vertical-align: top; + word-break: break-all; +} + +.file-details-error { + color: #f00; +} + +.file-details-thumbnail { + max-width: 180px; +} diff --git a/src/web/utils/fileDetails.mjs b/src/web/utils/fileDetails.mjs new file mode 100644 index 00000000..f8e3003b --- /dev/null +++ b/src/web/utils/fileDetails.mjs @@ -0,0 +1,134 @@ +/** + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2022 + * @license Apache-2.0 + */ + +import {showSidePanel} from "./sidePanel.mjs"; +import Utils from "../../core/Utils.mjs"; +import {isImage} from "../../core/lib/FileType.mjs"; + +/** + * A File Details extension for CodeMirror + */ +class FileDetailsPanel { + + /** + * FileDetailsPanel constructor + * @param {Object} opts + */ + constructor(opts) { + this.fileDetails = opts.fileDetails; + this.progress = opts.progress; + this.status = opts.status; + this.buffer = opts.buffer; + this.renderPreview = opts.renderPreview; + this.dom = this.buildDOM(); + this.renderFileThumb(); + } + + /** + * Builds the file details DOM tree + * @returns {DOMNode} + */ + buildDOM() { + const dom = document.createElement("div"); + + dom.className = "cm-file-details"; + const fileThumb = require("../static/images/file-128x128.png"); + dom.innerHTML = ` +

File details

+ + + + + + + + + + + + + + + + + + +
Name: + ${Utils.escapeHtml(this.fileDetails.name)} +
Size: + ${Utils.escapeHtml(this.fileDetails.size)} bytes +
Type: + ${Utils.escapeHtml(this.fileDetails.type)} +
Loaded: + ${this.status === "error" ? "Error" : this.progress + "%"} +
+ `; + + return dom; + } + + /** + * Render the file thumbnail + */ + renderFileThumb() { + if (!this.renderPreview) { + this.resetFileThumb(); + return; + } + const fileThumb = this.dom.querySelector(".file-details-thumbnail"); + const fileType = this.dom.querySelector(".file-details-type"); + const fileBuffer = new Uint8Array(this.buffer); + const type = isImage(fileBuffer); + + if (type && type !== "image/tiff" && fileBuffer.byteLength <= 512000) { + // Most browsers don't support displaying TIFFs, so ignore them + // Don't render images over 512,000 bytes + const blob = new Blob([fileBuffer], {type: type}), + url = URL.createObjectURL(blob); + fileThumb.src = url; + } else { + this.resetFileThumb(); + } + fileType.textContent = type; + } + + /** + * Reset the file thumbnail to the default icon + */ + resetFileThumb() { + const fileThumb = this.dom.querySelector(".file-details-thumbnail"); + fileThumb.src = require("../static/images/file-128x128.png"); + } + +} + +/** + * A panel constructor factory building a panel that displays file details + * @param {Object} opts + * @returns {Function} + */ +function makePanel(opts) { + const fdPanel = new FileDetailsPanel(opts); + + return (view) => { + return { + dom: fdPanel.dom, + width: 200, + update(update) { + } + }; + }; +} + +/** + * A function that build the extension that enables the panel in an editor. + * @param {Object} opts + * @returns {Extension} + */ +export function fileDetailsPanel(opts) { + const panelMaker = makePanel(opts); + return showSidePanel.of(panelMaker); +} diff --git a/src/web/utils/sidePanel.mjs b/src/web/utils/sidePanel.mjs new file mode 100644 index 00000000..a8de0931 --- /dev/null +++ b/src/web/utils/sidePanel.mjs @@ -0,0 +1,254 @@ +/** + * A modification of the CodeMirror Panel extension to enable panels to the + * left and right of the editor. + * Based on code here: https://github.com/codemirror/view/blob/main/src/panel.ts + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2022 + * @license Apache-2.0 + */ + +import {EditorView, ViewPlugin} from "@codemirror/view"; +import {Facet} from "@codemirror/state"; + +const panelConfig = Facet.define({ + combine(configs) { + let leftContainer, rightContainer; + for (const c of configs) { + leftContainer = leftContainer || c.leftContainer; + rightContainer = rightContainer || c.rightContainer; + } + return {leftContainer, rightContainer}; + } +}); + +/** + * Configures the panel-managing extension. + * @param {PanelConfig} config + * @returns Extension + */ +export function panels(config) { + return config ? [panelConfig.of(config)] : []; +} + +/** + * Get the active panel created by the given constructor, if any. + * This can be useful when you need access to your panels' DOM + * structure. + * @param {EditorView} view + * @param {PanelConstructor} panel + * @returns {Panel} + */ +export function getPanel(view, panel) { + const plugin = view.plugin(panelPlugin); + const index = plugin ? plugin.specs.indexOf(panel) : -1; + return index > -1 ? plugin.panels[index] : null; +} + +const panelPlugin = ViewPlugin.fromClass(class { + + /** + * @param {EditorView} view + */ + constructor(view) { + this.input = view.state.facet(showSidePanel); + this.specs = this.input.filter(s => s); + this.panels = this.specs.map(spec => spec(view)); + const conf = view.state.facet(panelConfig); + this.left = new PanelGroup(view, true, conf.leftContainer); + this.right = new PanelGroup(view, false, conf.rightContainer); + this.left.sync(this.panels.filter(p => p.left)); + this.right.sync(this.panels.filter(p => !p.left)); + for (const p of this.panels) { + p.dom.classList.add("cm-panel"); + if (p.mount) p.mount(); + } + } + + /** + * @param {ViewUpdate} update + */ + update(update) { + const conf = update.state.facet(panelConfig); + if (this.left.container !== conf.leftContainer) { + this.left.sync([]); + this.left = new PanelGroup(update.view, true, conf.leftContainer); + } + if (this.right.container !== conf.rightContainer) { + this.right.sync([]); + this.right = new PanelGroup(update.view, false, conf.rightContainer); + } + this.left.syncClasses(); + this.right.syncClasses(); + const input = update.state.facet(showSidePanel); + if (input !== this.input) { + const specs = input.filter(x => x); + const panels = [], left = [], right = [], mount = []; + for (const spec of specs) { + const known = this.specs.indexOf(spec); + let panel; + if (known < 0) { + panel = spec(update.view); + mount.push(panel); + } else { + panel = this.panels[known]; + if (panel.update) panel.update(update); + } + panels.push(panel) + ;(panel.left ? left : right).push(panel); + } + this.specs = specs; + this.panels = panels; + this.left.sync(left); + this.right.sync(right); + for (const p of mount) { + p.dom.classList.add("cm-panel"); + if (p.mount) p.mount(); + } + } else { + for (const p of this.panels) if (p.update) p.update(update); + } + } + + /** + * Destroy panel + */ + destroy() { + this.left.sync([]); + this.right.sync([]); + } +}, { + // provide: PluginField.scrollMargins.from(value => ({left: value.left.scrollMargin(), right: value.right.scrollMargin()})) +}); + +/** + * PanelGroup + */ +class PanelGroup { + + /** + * @param {EditorView} view + * @param {boolean} left + * @param {HTMLElement} container + */ + constructor(view, left, container) { + this.view = view; + this.left = left; + this.container = container; + this.dom = undefined; + this.classes = ""; + this.panels = []; + this.bufferWidth = 0; + this.syncClasses(); + } + + /** + * @param {Panel[]} panels + */ + sync(panels) { + for (const p of this.panels) if (p.destroy && panels.indexOf(p) < 0) p.destroy(); + this.panels = panels; + this.syncDOM(); + } + + /** + * Synchronise the DOM + */ + syncDOM() { + if (this.panels.length === 0) { + if (this.dom) { + this.dom.remove(); + this.dom = undefined; + } + return; + } + + const parent = this.container || this.view.dom; + if (!this.dom) { + this.dom = document.createElement("div"); + this.dom.className = this.left ? "cm-side-panels cm-panels-left" : "cm-side-panels cm-panels-right"; + parent.insertBefore(this.dom, parent.firstChild); + } + + let curDOM = this.dom.firstChild; + for (const panel of this.panels) { + if (panel.dom.parentNode === this.dom) { + while (curDOM !== panel.dom) curDOM = rm(curDOM); + curDOM = curDOM.nextSibling; + } else { + this.dom.insertBefore(panel.dom, curDOM); + this.bufferWidth = panel.width; + panel.dom.style.width = panel.width + "px"; + this.dom.style.width = this.bufferWidth + "px"; + } + } + while (curDOM) curDOM = rm(curDOM); + + const margin = this.left ? "marginLeft" : "marginRight"; + parent.querySelector(".cm-scroller").style[margin] = this.bufferWidth + "px"; + } + + /** + * + */ + scrollMargin() { + return !this.dom || this.container ? 0 : + Math.max(0, this.left ? + this.dom.getBoundingClientRect().right - Math.max(0, this.view.scrollDOM.getBoundingClientRect().left) : + Math.min(innerHeight, this.view.scrollDOM.getBoundingClientRect().right) - this.dom.getBoundingClientRect().left); + } + + /** + * + */ + syncClasses() { + if (!this.container || this.classes === this.view.themeClasses) return; + for (const cls of this.classes.split(" ")) if (cls) this.container.classList.remove(cls); + for (const cls of (this.classes = this.view.themeClasses).split(" ")) if (cls) this.container.classList.add(cls); + } +} + +/** + * @param {ChildNode} node + * @returns HTMLElement + */ +function rm(node) { + const next = node.nextSibling; + node.remove(); + return next; +} + +const baseTheme = EditorView.baseTheme({ + ".cm-side-panels": { + boxSizing: "border-box", + position: "absolute", + height: "100%", + top: 0, + bottom: 0 + }, + "&light .cm-side-panels": { + backgroundColor: "#f5f5f5", + color: "black" + }, + "&light .cm-panels-left": { + borderRight: "1px solid #ddd", + left: 0 + }, + "&light .cm-panels-right": { + borderLeft: "1px solid #ddd", + right: 0 + }, + "&dark .cm-side-panels": { + backgroundColor: "#333338", + color: "white" + } +}); + +/** + * Opening a panel is done by providing a constructor function for + * the panel through this facet. (The panel is closed again when its + * constructor is no longer provided.) Values of `null` are ignored. + */ +export const showSidePanel = Facet.define({ + enables: [panelPlugin, baseTheme] +}); diff --git a/src/web/waiters/InputWaiter.mjs b/src/web/waiters/InputWaiter.mjs index caa1a098..000940a4 100644 --- a/src/web/waiters/InputWaiter.mjs +++ b/src/web/waiters/InputWaiter.mjs @@ -9,7 +9,6 @@ import LoaderWorker from "worker-loader?inline=no-fallback!../workers/LoaderWork import InputWorker from "worker-loader?inline=no-fallback!../workers/InputWorker.mjs"; import Utils, {debounce} from "../../core/Utils.mjs"; import {toBase64} from "../../core/lib/Base64.mjs"; -import {isImage} from "../../core/lib/FileType.mjs"; import cptable from "codepage"; import { @@ -21,6 +20,7 @@ import {bracketMatching} from "@codemirror/language"; import {search, searchKeymap, highlightSelectionMatches} from "@codemirror/search"; import {statusBar} from "../utils/statusBar.mjs"; +import {fileDetailsPanel} from "../utils/fileDetails.mjs"; import {renderSpecialChar} from "../utils/editorUtils.mjs"; @@ -65,7 +65,8 @@ class InputWaiter { initEditor() { this.inputEditorConf = { eol: new Compartment, - lineWrapping: new Compartment + lineWrapping: new Compartment, + fileDetailsPanel: new Compartment }; const initialState = EditorState.create({ @@ -92,6 +93,7 @@ class InputWaiter { }), // Mutable state + this.inputEditorConf.fileDetailsPanel.of([]), this.inputEditorConf.lineWrapping.of(EditorView.lineWrapping), this.inputEditorConf.eol.of(EditorState.lineSeparator.of("\n")), @@ -466,43 +468,32 @@ class InputWaiter { if (inputNum !== activeTab) return; if (inputData.file) { - this.setFile(inputNum, inputData, silent); + this.setFile(inputNum, inputData); } else { - // TODO Per-tab encodings? - let inputVal; - if (this.inputChrEnc > 0) { - inputVal = cptable.utils.decode(this.inputChrEnc, new Uint8Array(inputData.buffer)); - } else { - inputVal = Utils.arrayBufferToStr(inputData.buffer); - } - - this.setInput(inputVal); - const fileOverlay = document.getElementById("input-file"), - fileName = document.getElementById("input-file-name"), - fileSize = document.getElementById("input-file-size"), - fileType = document.getElementById("input-file-type"), - fileLoaded = document.getElementById("input-file-loaded"); - - fileOverlay.style.display = "none"; - fileName.textContent = ""; - fileSize.textContent = ""; - fileType.textContent = ""; - fileLoaded.textContent = ""; - - this.inputTextEl.classList.remove("blur"); - - // Set URL to current input - if (inputVal.length >= 0 && inputVal.length <= 51200) { - const inputStr = toBase64(inputVal, "A-Za-z0-9+/"); - this.setUrl({ - includeInput: true, - input: inputStr - }); - } - - if (!silent) window.dispatchEvent(this.manager.statechange); + this.clearFile(inputNum); } + // TODO Per-tab encodings? + let inputVal; + if (this.inputChrEnc > 0) { + inputVal = cptable.utils.decode(this.inputChrEnc, new Uint8Array(inputData.buffer)); + } else { + inputVal = Utils.arrayBufferToStr(inputData.buffer); + } + + this.setInput(inputVal); + + // Set URL to current input + if (inputVal.length >= 0 && inputVal.length <= 51200) { + const inputStr = toBase64(inputVal, "A-Za-z0-9+/"); + this.setUrl({ + includeInput: true, + input: inputStr + }); + } + + if (!silent) window.dispatchEvent(this.manager.statechange); + }.bind(this)); } @@ -520,33 +511,38 @@ class InputWaiter { * @param {string} file.type * @param {string} status * @param {number} progress - * @param {boolean} [silent=true] - If false, fires the manager statechange event */ - setFile(inputNum, inputData, silent=true) { + setFile(inputNum, inputData) { const activeTab = this.manager.tabs.getActiveInputTab(); if (inputNum !== activeTab) return; - const fileOverlay = document.getElementById("input-file"), - fileName = document.getElementById("input-file-name"), - fileSize = document.getElementById("input-file-size"), - fileType = document.getElementById("input-file-type"), - fileLoaded = document.getElementById("input-file-loaded"); + // Create file details panel + this.inputEditorView.dispatch({ + effects: this.inputEditorConf.fileDetailsPanel.reconfigure( + fileDetailsPanel({ + fileDetails: inputData.file, + progress: inputData.progress, + status: inputData.status, + buffer: inputData.buffer, + renderPreview: this.app.options.imagePreview + }) + ) + }); + } - fileOverlay.style.display = "block"; - fileName.textContent = inputData.file.name; - fileSize.textContent = inputData.file.size + " bytes"; - fileType.textContent = inputData.file.type; - if (inputData.status === "error") { - fileLoaded.textContent = "Error"; - fileLoaded.style.color = "#FF0000"; - } else { - fileLoaded.style.color = ""; - fileLoaded.textContent = inputData.progress + "%"; - } + /** + * Clears the file details panel + * + * @param {number} inputNum + */ + clearFile(inputNum) { + const activeTab = this.manager.tabs.getActiveInputTab(); + if (inputNum !== activeTab) return; - this.displayFilePreview(inputNum, inputData); - - if (!silent) window.dispatchEvent(this.manager.statechange); + // Clear file details panel + this.inputEditorView.dispatch({ + effects: this.inputEditorConf.fileDetailsPanel.reconfigure([]) + }); } /** @@ -571,60 +567,6 @@ class InputWaiter { this.updateFileProgress(inputNum, 100); } - /** - * Render the input thumbnail - */ - async renderFileThumb() { - const activeTab = this.manager.tabs.getActiveInputTab(), - input = await this.getInputValue(activeTab), - fileThumb = document.getElementById("input-file-thumbnail"); - - if (typeof input === "string" || - !this.app.options.imagePreview) { - this.resetFileThumb(); - return; - } - - const inputArr = new Uint8Array(input), - type = isImage(inputArr); - - if (type && type !== "image/tiff" && inputArr.byteLength <= 512000) { - // Most browsers don't support displaying TIFFs, so ignore them - // Don't render images over 512000 bytes - const blob = new Blob([inputArr], {type: type}), - url = URL.createObjectURL(blob); - fileThumb.src = url; - } else { - this.resetFileThumb(); - } - - } - - /** - * Reset the input thumbnail to the default icon - */ - resetFileThumb() { - const fileThumb = document.getElementById("input-file-thumbnail"); - fileThumb.src = require("../static/images/file-128x128.png").default; - } - - /** - * Shows a chunk of the file in the input behind the file overlay - * - * @param {number} inputNum - The inputNum of the file being displayed - * @param {Object} inputData - Object containing the input data - * @param {string} inputData.stringSample - The first 4096 bytes of input as a string - */ - displayFilePreview(inputNum, inputData) { - const activeTab = this.manager.tabs.getActiveInputTab(), - input = inputData.buffer; - if (inputNum !== activeTab) return; - this.inputTextEl.classList.add("blur"); - this.setInput(input.stringSample); - - this.renderFileThumb(); - } - /** * Updates the displayed load progress for a file * @@ -632,17 +574,19 @@ class InputWaiter { * @param {number | string} progress - Either a number or "error" */ updateFileProgress(inputNum, progress) { - const activeTab = this.manager.tabs.getActiveInputTab(); - if (inputNum !== activeTab) return; + // const activeTab = this.manager.tabs.getActiveInputTab(); + // if (inputNum !== activeTab) return; - const fileLoaded = document.getElementById("input-file-loaded"); - if (progress === "error") { - fileLoaded.textContent = "Error"; - fileLoaded.style.color = "#FF0000"; - } else { - fileLoaded.textContent = progress + "%"; - fileLoaded.style.color = ""; - } + // TODO + + // const fileLoaded = document.getElementById("input-file-loaded"); + // if (progress === "error") { + // fileLoaded.textContent = "Error"; + // fileLoaded.style.color = "#FF0000"; + // } else { + // fileLoaded.textContent = progress + "%"; + // fileLoaded.style.color = ""; + // } } /** @@ -778,10 +722,6 @@ class InputWaiter { */ inputChange(e) { debounce(function(e) { - // Ignore this function if the input is a file - const fileOverlay = document.getElementById("input-file"); - if (fileOverlay.style.display === "block") return; - const value = this.getInput(); const activeTab = this.manager.tabs.getActiveInputTab(); @@ -806,7 +746,7 @@ class InputWaiter { e.stopPropagation(); e.preventDefault(); - e.target.closest("#input-text,#input-file").classList.add("dropping-file"); + e.target.closest("#input-text").classList.add("dropping-file"); } /** @@ -821,7 +761,7 @@ class InputWaiter { // Dragleave often fires when moving between lines in the editor. // If the target element is within the input-text element, we are still on target. if (!this.inputTextEl.contains(e.target)) - e.target.closest("#input-text,#input-file").classList.remove("dropping-file"); + e.target.closest("#input-text").classList.remove("dropping-file"); } /** @@ -837,7 +777,7 @@ class InputWaiter { e.stopPropagation(); e.preventDefault(); - e.target.closest("#input-text,#input-file").classList.remove("dropping-file"); + e.target.closest("#input-text").classList.remove("dropping-file"); // Dropped text is handled by the editor itself if (e.dataTransfer.getData("Text")) return; @@ -1063,23 +1003,6 @@ class InputWaiter { window.dispatchEvent(this.manager.statechange); } - /** - * Handler for clear IO click event. - * Resets the input for the current tab - */ - clearIoClick() { - const inputNum = this.manager.tabs.getActiveInputTab(); - if (inputNum === -1) return; - - this.updateInputValue(inputNum, "", true); - - this.set(inputNum, { - buffer: new ArrayBuffer() - }); - - this.manager.tabs.updateInputTabHeader(inputNum, ""); - } - /** * Sets the console log level in the worker. *