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 }) => {