diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index b075e30fb..4263621b0 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -951,6 +951,33 @@ pointer-events: auto; } + .lexxy-node-action { + align-items: center; + aspect-ratio: 1; + background: transparent; + block-size: var(--button-size); + border-radius: var(--floating-tools-radius); + color: var(--lexxy-color-ink-inverted); + display: flex; + justify-content: center; + min-block-size: var(--button-size); + min-inline-size: var(--button-size); + text-decoration: none; + + svg { + block-size: 1.125em; + inline-size: 1.125em; + fill: currentColor; + opacity: 0.8; + } + + &:hover { + background: var(--lexxy-color-ink-medium); + + svg { opacity: 1; } + } + } + .lexxy-node-delete:hover { background-color: var(--lexxy-color-red); } diff --git a/src/config/dom_purify.js b/src/config/dom_purify.js index 8c1ae74f9..50ab3ab62 100644 --- a/src/config/dom_purify.js +++ b/src/config/dom_purify.js @@ -5,7 +5,7 @@ import Lexxy from "./lexxy" const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em", "figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "u", "ul", "table", "tbody", "tr", "th", "td" ] -const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable", +const ALLOWED_HTML_ATTRIBUTES = [ "alt", "blob-url", "caption", "class", "collapsed", "content", "content-type", "contenteditable", "data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation", "previewable", "sgid", "src", "style", "title", "url", "width" ] diff --git a/src/elements/node_delete_button.js b/src/elements/node_delete_button.js index ef2f548a3..5f19a5716 100644 --- a/src/elements/node_delete_button.js +++ b/src/elements/node_delete_button.js @@ -5,6 +5,26 @@ const DELETE_ICON = ` ` +const PREVIEW_ICON = ` + +` + +const EDIT_ICON = ` + +` + +const DOWNLOAD_ICON = ` + +` + +const COLLAPSE_ICON = ` + +` + +const EXPAND_ICON = ` + +` + export class NodeDeleteButton extends HTMLElement { connectedCallback() { this.editorElement = this.closest("lexxy-editor") @@ -12,7 +32,7 @@ export class NodeDeleteButton extends HTMLElement { this.classList.add("lexxy-floating-controls") if (!this.querySelector(".lexxy-node-delete")) { - this.#attachDeleteButton() + this.#attachButtons() } } @@ -21,8 +41,91 @@ export class NodeDeleteButton extends HTMLElement { this.editorElement = null } - #attachDeleteButton() { + #attachButtons() { const container = createElement("div", { className: "lexxy-floating-controls__group" }) + const fileUrl = this.dataset.fileUrl + const fileName = this.dataset.fileName + const contentType = this.dataset.contentType + const caption = this.dataset.caption + + if (fileUrl) { + const previewButton = createElement("button", { + type: "button", + className: "lexxy-node-action", + "aria-label": "Open" + }) + previewButton.tabIndex = -1 + previewButton.dataset.tooltip = "Open" + previewButton.dataset.tooltipPosition = "below" + previewButton.innerHTML = PREVIEW_ICON + previewButton.addEventListener("click", (e) => { + e.stopPropagation() + this.#dispatchPreviewEvent(fileUrl, fileName, contentType, caption) + }) + container.appendChild(previewButton) + + if (this.#isEditable(contentType)) { + const editButton = createElement("button", { + type: "button", + className: "lexxy-node-action", + "aria-label": "Edit" + }) + editButton.tabIndex = -1 + editButton.dataset.tooltip = "Edit" + editButton.dataset.tooltipPosition = "below" + editButton.innerHTML = EDIT_ICON + editButton.addEventListener("click", (e) => { + e.stopPropagation() + this.#dispatchEditEvent(fileUrl, fileName, contentType, caption) + }) + container.appendChild(editButton) + } + + const downloadLink = createElement("a", { + href: fileUrl, + download: fileName || "", + className: "lexxy-node-action", + "aria-label": "Download" + }) + downloadLink.tabIndex = -1 + downloadLink.dataset.tooltip = "Download" + downloadLink.dataset.tooltipPosition = "below" + downloadLink.innerHTML = DOWNLOAD_ICON + downloadLink.addEventListener("click", (e) => e.stopPropagation()) + container.appendChild(downloadLink) + + if (this.dataset.previewable === "true") { + const isCollapsed = this.closest("figure.attachment")?.classList.contains("attachment--collapsed") + const toggleButton = createElement("button", { + type: "button", + className: "lexxy-node-action", + "aria-label": isCollapsed ? "Show preview" : "Collapse to card" + }) + toggleButton.tabIndex = -1 + toggleButton.dataset.tooltip = isCollapsed ? "Show preview" : "Collapse" + toggleButton.dataset.tooltipPosition = "below" + toggleButton.innerHTML = isCollapsed ? EXPAND_ICON : COLLAPSE_ICON + toggleButton.addEventListener("click", (e) => { + e.stopPropagation() + const figure = this.closest("figure.attachment") + if (figure) { + figure.classList.toggle("attachment--collapsed") + const nowCollapsed = figure.classList.contains("attachment--collapsed") + toggleButton.innerHTML = nowCollapsed ? EXPAND_ICON : COLLAPSE_ICON + toggleButton.setAttribute("aria-label", nowCollapsed ? "Show preview" : "Collapse to card") + toggleButton.dataset.tooltip = nowCollapsed ? "Show preview" : "Collapse" + + this.editor.update(() => { + const node = $getNearestNodeFromDOMNode(this) + if (node) { + node.getWritable().collapsed = nowCollapsed + } + }) + } + }) + container.appendChild(toggleButton) + } + } this.deleteButton = createElement("button", { className: "lexxy-node-delete", @@ -30,6 +133,8 @@ export class NodeDeleteButton extends HTMLElement { "aria-label": "Remove" }) this.deleteButton.tabIndex = -1 + this.deleteButton.dataset.tooltip = "Remove" + this.deleteButton.dataset.tooltipPosition = "below" this.deleteButton.innerHTML = DELETE_ICON this.handleDeleteClick = () => this.#deleteNode() @@ -39,6 +144,27 @@ export class NodeDeleteButton extends HTMLElement { this.appendChild(container) } + #isEditable(contentType) { + if (!contentType) return false + return contentType.startsWith("text/") || + contentType === "application/json" || + contentType === "application/csv" + } + + #dispatchEditEvent(fileUrl, fileName, contentType, caption) { + this.editorElement.dispatchEvent(new CustomEvent("lexxy:edit-attachment", { + bubbles: true, + detail: { fileUrl, fileName, contentType, caption } + })) + } + + #dispatchPreviewEvent(fileUrl, fileName, contentType, caption) { + this.editorElement.dispatchEvent(new CustomEvent("lexxy:preview-attachment", { + bubbles: true, + detail: { fileUrl, fileName, contentType, caption } + })) + } + #deleteNode() { this.editor.update(() => { const node = $getNearestNodeFromDOMNode(this) diff --git a/src/nodes/action_text_attachment_node.js b/src/nodes/action_text_attachment_node.js index 38c344c19..c09e01825 100644 --- a/src/nodes/action_text_attachment_node.js +++ b/src/nodes/action_text_attachment_node.js @@ -26,7 +26,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { node: new ActionTextAttachmentNode({ sgid: attachment.getAttribute("sgid"), src: attachment.getAttribute("url"), + blobUrl: attachment.getAttribute("blob-url"), previewable: attachment.getAttribute("previewable"), + collapsed: attachment.getAttribute("collapsed"), altText: attachment.getAttribute("alt"), caption: attachment.getAttribute("caption"), contentType: attachment.getAttribute("content-type"), @@ -79,13 +81,15 @@ export class ActionTextAttachmentNode extends DecoratorNode { return Lexxy.global.get("attachmentTagName") } - constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) { + constructor({ tagName, sgid, src, blobUrl, previewable, collapsed, altText, caption, contentType, fileName, fileSize, width, height }, key) { super(key) this.tagName = tagName || ActionTextAttachmentNode.TAG_NAME this.sgid = sgid this.src = src + this.blobUrl = blobUrl this.previewable = parseBoolean(previewable) + this.collapsed = collapsed != null ? parseBoolean(collapsed) : this.#defaultCollapsed(contentType) this.altText = altText || "" this.caption = caption || "" this.contentType = contentType || "" @@ -97,12 +101,31 @@ export class ActionTextAttachmentNode extends DecoratorNode { this.editor = $getEditor() } + get fileUrl() { + return this.blobUrl || this.src + } + + #defaultCollapsed(contentType) { + return contentType === "application/pdf" + } + createDOM() { const figure = this.createAttachmentFigure() if (this.isPreviewableAttachment) { - figure.appendChild(this.#createDOMForImage()) - figure.appendChild(this.#createEditableCaption()) + if (this.collapsed) { + figure.classList.add("attachment--collapsed") + } + + const previewView = createElement("div", { className: "attachment__preview-view" }) + previewView.appendChild(this.#createDOMForImage()) + previewView.appendChild(this.#createEditableCaption()) + figure.appendChild(previewView) + + const cardView = createElement("div", { className: "attachment__card-view" }) + cardView.appendChild(this.#createDOMForFile()) + cardView.appendChild(this.#createDOMForNotImage()) + figure.appendChild(cardView) } else { figure.appendChild(this.#createDOMForFile()) figure.appendChild(this.#createDOMForNotImage()) @@ -117,6 +140,24 @@ export class ActionTextAttachmentNode extends DecoratorNode { caption.value = this.caption } + const cardView = dom.querySelector(".attachment__card-view") + if (cardView) { + const captionText = cardView.querySelector(".attachment__caption-text") + if (this.caption) { + if (captionText) { + captionText.textContent = this.caption + } else { + const meta = cardView.querySelector(".attachment__meta") + if (meta) { + const newCaption = createElement("span", { className: "attachment__caption-text", textContent: this.caption }) + meta.prepend(newCaption) + } + } + } else if (captionText) { + captionText.remove() + } + } + return false } @@ -132,7 +173,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { const attachment = createElement(this.tagName, { sgid: this.sgid, previewable: this.previewable || null, + collapsed: this.collapsed ? "true" : null, url: this.src, + "blob-url": this.blobUrl || null, alt: this.altText, caption: this.caption, "content-type": this.contentType, @@ -153,7 +196,9 @@ export class ActionTextAttachmentNode extends DecoratorNode { tagName: this.tagName, sgid: this.sgid, src: this.src, + blobUrl: this.blobUrl, previewable: this.previewable, + collapsed: this.collapsed, altText: this.altText, caption: this.caption, contentType: this.contentType, @@ -173,8 +218,17 @@ export class ActionTextAttachmentNode extends DecoratorNode { figure.draggable = true figure.dataset.lexicalNodeKey = this.__key - const deleteButton = createElement("lexxy-node-delete-button") - figure.appendChild(deleteButton) + const controls = createElement("lexxy-node-delete-button") + if (this.fileUrl) { + controls.dataset.fileUrl = this.fileUrl + controls.dataset.fileName = this.fileName || "" + controls.dataset.contentType = this.contentType || "" + controls.dataset.caption = this.caption || "" + } + if (this.isPreviewableAttachment) { + controls.dataset.previewable = "true" + } + figure.appendChild(controls) return figure } @@ -223,23 +277,34 @@ export class ActionTextAttachmentNode extends DecoratorNode { } } + static FILE_TYPE_LABELS = { md: "M↓", png: "IMG", jpg: "IMG", jpeg: "IMG", gif: "IMG", webp: "IMG", svg: "IMG", xls: "XLS", xlsx: "XLS" } + #createDOMForFile() { - const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "unknown" - return createElement("span", { className: "attachment__icon", textContent: `${extension}` }) + const extension = this.fileName ? this.fileName.split(".").pop().toLowerCase() : "?" + const label = ActionTextAttachmentNode.FILE_TYPE_LABELS[extension] || extension.toUpperCase() + return createElement("span", { className: "attachment__icon", textContent: label }) } #createDOMForNotImage() { const figcaption = createElement("figcaption", { className: "attachment__caption" }) - const nameTag = createElement("strong", { className: "attachment__name", textContent: this.caption || this.fileName }) - + const nameTag = createElement("strong", { className: "attachment__name", textContent: this.fileName }) figcaption.appendChild(nameTag) + const metaRow = createElement("span", { className: "attachment__meta" }) + + if (this.caption) { + const captionTag = createElement("span", { className: "attachment__caption-text", textContent: this.caption }) + metaRow.appendChild(captionTag) + } + if (this.fileSize) { - const sizeSpan = createElement("span", { className: "attachment__size", textContent: bytesToHumanSize(this.fileSize) }) - figcaption.appendChild(sizeSpan) + const subtitle = createElement("span", { className: "attachment__subtitle", textContent: bytesToHumanSize(this.fileSize) }) + metaRow.appendChild(subtitle) } + figcaption.appendChild(metaRow) + return figcaption } diff --git a/src/nodes/action_text_attachment_upload_node.js b/src/nodes/action_text_attachment_upload_node.js index 441d72be2..ca5e49a64 100644 --- a/src/nodes/action_text_attachment_upload_node.js +++ b/src/nodes/action_text_attachment_upload_node.js @@ -258,7 +258,8 @@ class AttachmentNodeConversion { return new ActionTextAttachmentNode({ ...this.uploadNode, ...this.#propertiesFromBlob, - src: this.#src + src: this.#src, + blobUrl: this.#blobSrc }) } diff --git a/test/browser/tests/attachments/non_previewable_attachment.test.js b/test/browser/tests/attachments/non_previewable_attachment.test.js index e8a3e8635..f518b3212 100644 --- a/test/browser/tests/attachments/non_previewable_attachment.test.js +++ b/test/browser/tests/attachments/non_previewable_attachment.test.js @@ -29,8 +29,8 @@ test.describe("Non-previewable attachment", () => { await expect(figure).toBeVisible() await expect(figure).toHaveClass(/attachment--file/) await expect(figure.locator("img")).toHaveCount(0) - await expect(figure.locator(".attachment__icon")).toBeVisible() - await expect(figure.locator(".attachment__name")).toHaveText("protected.pdf") + await expect(figure.locator(".attachment__icon").first()).toBeVisible() + await expect(figure.locator(".attachment__name").first()).toHaveText("protected.pdf") }) test("broken preview image falls back to file rendering", async ({ page, editor }) => { @@ -45,8 +45,8 @@ test.describe("Non-previewable attachment", () => { // After onerror fires, the figure should swap to file rendering await expect(figure).toHaveClass(/attachment--file/, { timeout: 5000 }) await expect(figure.locator("img")).toHaveCount(0) - await expect(figure.locator(".attachment__icon")).toBeVisible() - await expect(figure.locator(".attachment__name")).toHaveText("protected.pdf") + await expect(figure.locator(".attachment__icon").first()).toBeVisible() + await expect(figure.locator(".attachment__name").first()).toHaveText("protected.pdf") }) test("exportDOM preserves previewable='true' after visual fallback", async ({ page, editor }) => {