diff --git a/frontend/src/components/TrixEditor.css b/frontend/src/components/TrixEditor.css index e6ef57e..a8e8428 100644 --- a/frontend/src/components/TrixEditor.css +++ b/frontend/src/components/TrixEditor.css @@ -239,6 +239,8 @@ trix-editor.trix-content .attachment--preview { trix-editor.trix-content .attachment img { display: inline-block; max-width: 100%; + max-height: 60vh; + width: auto; height: auto; border-radius: calc(var(--radius, 0.5rem) - 2px); /* `grab` rather than `move`: it reads as "pick this up", and pairs with the diff --git a/frontend/src/components/TrixEditor.tsx b/frontend/src/components/TrixEditor.tsx index 1214c3d..eb461fc 100644 --- a/frontend/src/components/TrixEditor.tsx +++ b/frontend/src/components/TrixEditor.tsx @@ -1,17 +1,17 @@ -import { useCallback, useEffect, useId, useRef, useState } from "react" -import "trix" -import "trix/dist/trix.css" -import "./TrixEditor.css" -import { apiBaseUrl } from "../auth/urls" -import { copyText } from "../lib/clipboard" -import MediaPicker, { type MediaPickerItem } from "./MediaPicker" +import { useCallback, useEffect, useId, useRef, useState } from "react"; +import "trix"; +import "trix/dist/trix.css"; +import "./TrixEditor.css"; +import { apiBaseUrl } from "../auth/urls"; +import { copyText } from "../lib/clipboard"; +import MediaPicker, { type MediaPickerItem } from "./MediaPicker"; import { ALIGNMENTS, TRIX_ALIGN_CLASS, articleHtmlToTrix, contentTypeForUrl, trixHtmlToArticle, -} from "./trixImageHtml" +} from "./trixImageHtml"; // Leave the caption area of an image empty until it has a real caption. Trix's // default is to fill it with the filename and file size, which reads as a @@ -20,8 +20,8 @@ import { // Non-previewable file attachments are unaffected: Trix forces the name on for // those, and a file stub with no label would be nothing at all. if (typeof window !== "undefined" && window.Trix) { - window.Trix.config.attachments.preview.caption.name = false - window.Trix.config.attachments.preview.caption.size = false + window.Trix.config.attachments.preview.caption.name = false; + window.Trix.config.attachments.preview.caption.size = false; // Keep every image in a block of its own. Trix tags previewable attachments // with presentation "gallery" by default, and its attachmentGalleryFilter @@ -31,7 +31,7 @@ if (typeof window !== "undefined" && window.Trix) { // moved both, and swapping them with each other was not expressible at all. // Nothing here styles galleries, so switching the presentation off costs // nothing and leaves the filter with no run to ever match. - window.Trix.config.attachments.preview.presentation = null + window.Trix.config.attachments.preview.presentation = null; // Let alignment survive on the attachment piece. Trix's permitted list is // ["caption", "presentation"] and removeProhibitedAttributes drops everything @@ -41,7 +41,7 @@ if (typeof window !== "undefined" && window.Trix) { // -- which editing a caption does -- at which point the alignment silently // vanished from both the editor and the saved article. if (!window.Trix.AttachmentPiece.permittedAttributes.includes("align")) { - window.Trix.AttachmentPiece.permittedAttributes.push("align") + window.Trix.AttachmentPiece.permittedAttributes.push("align"); } } @@ -50,53 +50,54 @@ if (typeof window !== "undefined" && window.Trix) { // These are lucide's own Copy and Check paths, so the button reads the same as // "Copy article link" in the editor header. const svgIcon = (paths: string[]): SVGSVGElement => { - const ns = "http://www.w3.org/2000/svg" - const svg = document.createElementNS(ns, "svg") - svg.setAttribute("viewBox", "0 0 24 24") - svg.setAttribute("fill", "none") - svg.setAttribute("stroke", "currentColor") - svg.setAttribute("stroke-width", "2") - svg.setAttribute("stroke-linecap", "round") - svg.setAttribute("stroke-linejoin", "round") - svg.setAttribute("aria-hidden", "true") - svg.classList.add("trix-icon") + const ns = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(ns, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "2"); + svg.setAttribute("stroke-linecap", "round"); + svg.setAttribute("stroke-linejoin", "round"); + svg.setAttribute("aria-hidden", "true"); + svg.classList.add("trix-icon"); for (const d of paths) { - const path = document.createElementNS(ns, "path") - path.setAttribute("d", d) - svg.appendChild(path) + const path = document.createElementNS(ns, "path"); + path.setAttribute("d", d); + svg.appendChild(path); } - return svg -} + return svg; +}; -const copyIcon = () => svgIcon([ - "M20 9h-9a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2z", - "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1", -]) +const copyIcon = () => + svgIcon([ + "M20 9h-9a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2z", + "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1", + ]); -const checkIcon = () => svgIcon(["M20 6 9 17l-5-5"]) +const checkIcon = () => svgIcon(["M20 6 9 17l-5-5"]); // Hosts we may embed directly. A pasted image already served from our own media // infrastructure needs no sideload — it is the same file we would be copying. const isOwnMediaUrl = (url: string): boolean => { try { - const resolved = new URL(url, window.location.href) - if (resolved.origin === window.location.origin) return true - const apiOrigin = new URL(apiBaseUrl(), window.location.href).origin - return resolved.origin === apiOrigin + const resolved = new URL(url, window.location.href); + if (resolved.origin === window.location.origin) return true; + const apiOrigin = new URL(apiBaseUrl(), window.location.href).origin; + return resolved.origin === apiOrigin; } catch { - return false + return false; } -} +}; type TrixEditorProps = { - value: string - onChange: (html: string) => void -} + value: string; + onChange: (html: string) => void; +}; function TrixEditor({ value, onChange }: TrixEditorProps) { - const toolbarId = useId() - const editorRef = useRef(null) - const wrapperRef = useRef(null) + const toolbarId = useId(); + const editorRef = useRef(null); + const wrapperRef = useRef(null); // Every HTML string we have emitted since the last load from outside, so we // never call loadHTML on our own output -- which would reset the document and // drop the caret at the top of the article mid-edit. @@ -108,43 +109,43 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // editor is reloaded from it. That is the "type fast and your text jumps to // the top" bug: each reload rewound the document and reset the caret to 0, so // the following keystrokes landed at the start of the article. - const emittedRef = useRef>(new Set()) + const emittedRef = useRef>(new Set()); // Bounded so a long editing session (one entry per keystroke) doesn't grow // without limit. Re-inserting keeps the set in least-recently-emitted order, // so eviction drops the entries least likely to still be in flight. const rememberEmitted = useCallback((html: string) => { - const emitted = emittedRef.current - emitted.delete(html) - emitted.add(html) + const emitted = emittedRef.current; + emitted.delete(html); + emitted.add(html); while (emitted.size > 100) { - const oldest = emitted.values().next() - if (oldest.done) break - emitted.delete(oldest.value) + const oldest = emitted.values().next(); + if (oldest.done) break; + emitted.delete(oldest.value); } - }, []) + }, []); // Caret position captured before the picker steals focus, so the image lands // where the author was typing rather than at the top of the document. - const savedRangeRef = useRef<[number, number] | null>(null) + const savedRangeRef = useRef<[number, number] | null>(null); - const [pickerOpen, setPickerOpen] = useState(false) - const [notice, setNotice] = useState(null) + const [pickerOpen, setPickerOpen] = useState(false); + const [notice, setNotice] = useState(null); useEffect(() => { - const editor = editorRef.current - if (!editor) return + const editor = editorRef.current; + if (!editor) return; // An echo of our own output, however far behind. Reloading would only undo // edits the author has already made. - if (emittedRef.current.has(value)) return + if (emittedRef.current.has(value)) return; // A genuine load from outside: the editor is about to hold exactly this, so // nothing emitted before it can still be worth honouring. - emittedRef.current.clear() - emittedRef.current.add(value) - editor.editor.loadHTML(articleHtmlToTrix(value)) - }, [value]) + emittedRef.current.clear(); + emittedRef.current.add(value); + editor.editor.loadHTML(articleHtmlToTrix(value)); + }, [value]); useEffect(() => { - const editor = editorRef.current - if (!editor) return + const editor = editorRef.current; + if (!editor) return; const handleChange = () => { // Emit the semantic markup we persist, not Trix's internal attachment @@ -152,42 +153,53 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // against the incoming value prop to decide whether to reload the editor, // and reloading on our own output would reset the caret on every // keystroke. - const html = trixHtmlToArticle(editor.value) - rememberEmitted(html) - onChange(html) - } + const html = trixHtmlToArticle(editor.value); + rememberEmitted(html); + onChange(html); + }; - editor.addEventListener("trix-change", handleChange) - return () => { editor.removeEventListener("trix-change", handleChange) } - }, [onChange, rememberEmitted]) + editor.addEventListener("trix-change", handleChange); + return () => { + editor.removeEventListener("trix-change", handleChange); + }; + }, [onChange, rememberEmitted]); // Reflect each attachment's stored alignment onto the live figure so our CSS // can style it. Alignment round-trips as a data-trix-attributes value (Trix // rebuilds attachment figures from that JSON and would discard a bare class), // so the class has to be re-applied after every change. useEffect(() => { - const editor = editorRef.current - if (!editor) return + const editor = editorRef.current; + if (!editor) return; const applyAlignment = () => { - for (const figure of Array.from(editor.querySelectorAll("figure[data-trix-attributes]"))) { - let align: string | undefined + for (const figure of Array.from( + editor.querySelectorAll("figure[data-trix-attributes]"), + )) { + let align: string | undefined; try { - const parsed = JSON.parse(figure.getAttribute("data-trix-attributes") ?? "{}") as { align?: string } - align = parsed.align + const parsed = JSON.parse( + figure.getAttribute("data-trix-attributes") ?? "{}", + ) as { align?: string }; + align = parsed.align; } catch { - continue + continue; } for (const candidate of ALIGNMENTS) { - figure.classList.toggle(TRIX_ALIGN_CLASS[candidate], align === candidate) + figure.classList.toggle( + TRIX_ALIGN_CLASS[candidate], + align === candidate, + ); } } - } + }; - applyAlignment() - editor.addEventListener("trix-change", applyAlignment) - return () => { editor.removeEventListener("trix-change", applyAlignment) } - }, []) + applyAlignment(); + editor.addEventListener("trix-change", applyAlignment); + return () => { + editor.removeEventListener("trix-change", applyAlignment); + }; + }, []); // Handles every way an image enters the document. Trix fires // trix-attachment-add for all of them, and which branch runs depends on what @@ -200,8 +212,8 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // into our own library server-side so the article does not hotlink. // • one of our own URLs — inserted from the picker; nothing to do. useEffect(() => { - const editor = editorRef.current - if (!editor) return + const editor = editorRef.current; + if (!editor) return; // A failed attachment is removed rather than left behind. Its preview is a // blob: URL that exists only in this tab: it looks like a working image, @@ -209,64 +221,74 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // place invites the author to publish an article whose image silently // isn't there. Removing it and saying why is the honest outcome. const failAttachment = (attachment: TrixAttachment, message: string) => { - attachment.setUploadProgress(100) - attachment.remove() - setNotice(message) - } + attachment.setUploadProgress(100); + attachment.remove(); + setNotice(message); + }; const uploadFile = (attachment: TrixAttachment, file: File) => { - const xhr = new XMLHttpRequest() - xhr.open("POST", `${apiBaseUrl()}/v1/media`, true) + const xhr = new XMLHttpRequest(); + xhr.open("POST", `${apiBaseUrl()}/v1/media`, true); // The upload endpoint is session-authenticated, and the API commonly runs // on a different origin than the CMS, where XHR omits cookies by default. - xhr.withCredentials = true + xhr.withCredentials = true; xhr.upload.onprogress = (progressEvent: ProgressEvent) => { if (progressEvent.lengthComputable) { - attachment.setUploadProgress((progressEvent.loaded / progressEvent.total) * 100) + attachment.setUploadProgress( + (progressEvent.loaded / progressEvent.total) * 100, + ); } - } + }; xhr.onload = () => { if (xhr.status !== 201) { - let detail = `upload failed (${String(xhr.status)})` + let detail = `upload failed (${String(xhr.status)})`; try { - const body = JSON.parse(xhr.responseText) as { error?: string } - if (body.error?.trim()) detail = body.error.trim() + const body = JSON.parse(xhr.responseText) as { error?: string }; + if (body.error?.trim()) detail = body.error.trim(); } catch { // Non-JSON error body; the status code is all we can report. } - failAttachment(attachment, `Could not add ${file.name}: ${detail}`) - return + failAttachment(attachment, `Could not add ${file.name}: ${detail}`); + return; } try { - const { url } = JSON.parse(xhr.responseText) as { url: string } + const { url } = JSON.parse(xhr.responseText) as { url: string }; // href only for non-images. Trix wraps an attachment carrying an href // in an , which for a previewable image swallows every click on the // figure -- including the caption field. A file stub, by contrast, has // nothing to edit and a download link is the whole point of it. - attachment.setAttributes(file.type.startsWith("image/") ? { url } : { url, href: url }) - attachment.setUploadProgress(100) + attachment.setAttributes( + file.type.startsWith("image/") ? { url } : { url, href: url }, + ); + attachment.setUploadProgress(100); } catch { - failAttachment(attachment, `Could not add ${file.name}: unexpected server response.`) + failAttachment( + attachment, + `Could not add ${file.name}: unexpected server response.`, + ); } - } + }; xhr.onerror = () => { - failAttachment(attachment, `Could not add ${file.name}: the upload did not reach the server.`) - } + failAttachment( + attachment, + `Could not add ${file.name}: the upload did not reach the server.`, + ); + }; - const formData = new FormData() - formData.append("file", attachment.file ?? file) - xhr.send(formData) - } + const formData = new FormData(); + formData.append("file", attachment.file ?? file); + xhr.send(formData); + }; // Pasting rich text from another site brings tags pointing at that // site. Embedding them as-is would leave the published article depending on // a third party's server, so take our own copy instead. The fetch is done // server-side because the browser cannot read cross-origin image bytes. const sideloadRemote = (attachment: TrixAttachment, sourceUrl: string) => { - attachment.setUploadProgress(5) + attachment.setUploadProgress(5); fetch(`${apiBaseUrl()}/v1/media/fetch`, { method: "POST", credentials: "include", @@ -275,21 +297,21 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { }) .then(async (response) => { if (!response.ok) { - let detail = `import failed (${String(response.status)})` + let detail = `import failed (${String(response.status)})`; try { - const body = (await response.json()) as { error?: string } - if (body.error?.trim()) detail = body.error.trim() + const body = (await response.json()) as { error?: string }; + if (body.error?.trim()) detail = body.error.trim(); } catch { // Fall through to the status-code message. } - throw new Error(detail) + throw new Error(detail); } - return (await response.json()) as { url: string } + return (await response.json()) as { url: string }; }) .then(({ url }) => { // No href: this path only ever handles pasted images. See uploadFile. - attachment.setAttributes({ url }) - attachment.setUploadProgress(100) + attachment.setAttributes({ url }); + attachment.setUploadProgress(100); }) .catch((err: unknown) => { // Unlike a failed upload there is no local copy to fall back on, so @@ -297,75 +319,128 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { failAttachment( attachment, `Could not import the pasted image: ${err instanceof Error ? err.message : "import failed"}`, - ) - }) - } + ); + }); + }; const handleAttachmentAdd = (event: Event) => { - const { attachment } = event as TrixAttachmentAddEvent + const { attachment } = event as TrixAttachmentAddEvent; if (attachment.file) { - uploadFile(attachment, attachment.file) - return + uploadFile(attachment, attachment.file); + return; } - const url = typeof attachment.getAttribute("url") === "string" ? String(attachment.getAttribute("url")) : "" - if (!url) return + const url = + typeof attachment.getAttribute("url") === "string" + ? String(attachment.getAttribute("url")) + : ""; + if (!url) return; // Already ours (picker insert, or an image copied from another article in // this CMS) — re-importing would just duplicate the file. - if (isOwnMediaUrl(url)) return + if (isOwnMediaUrl(url)) return; // A blob:/data: URL has no server to fetch from; it arrives with a File // in every path we support, so reaching here means there is nothing to do. - if (url.startsWith("blob:") || url.startsWith("data:")) return + if (url.startsWith("blob:") || url.startsWith("data:")) return; - sideloadRemote(attachment, url) - } + sideloadRemote(attachment, url); + }; - editor.addEventListener("trix-attachment-add", handleAttachmentAdd) - return () => { editor.removeEventListener("trix-attachment-add", handleAttachmentAdd) } - }, []) + editor.addEventListener("trix-attachment-add", handleAttachmentAdd); + return () => { + editor.removeEventListener("trix-attachment-add", handleAttachmentAdd); + }; + }, []); + + const openBlockForImage = useCallback(() => { + const editor = editorRef.current; + if (!editor) return; + const { composition } = editor.editor; + + const blockAtCaret = () => { + const doc = editor.editor.getDocument(); + const [caret] = editor.editor.getSelectedRange(); + const { index } = doc.locationFromPosition(caret); + return { doc, index, block: doc.getBlockAtIndex(index) }; + }; + + if (!blockAtCaret().block?.isEmpty()) composition.insertBlockBreak(); + + const { doc, index } = blockAtCaret(); + if (!doc.getBlockAtIndex(index + 1)?.isEmpty()) { + const [imagePosition] = editor.editor.getSelectedRange(); + composition.insertBlockBreak(); + editor.editor.setSelectedRange([imagePosition, imagePosition]); + } + }, []); // Insert a library image at the caret. Alt text comes from the library record, // so an image described once is described everywhere it is used. - const insertFromLibrary = useCallback((item: MediaPickerItem) => { - setPickerOpen(false) - const editor = editorRef.current - const Trix = window.Trix - if (!editor || !Trix) return - - editor.focus() - if (savedRangeRef.current) { - editor.editor.setSelectedRange(savedRangeRef.current) - savedRangeRef.current = null - } + const insertFromLibrary = useCallback( + (item: MediaPickerItem) => { + setPickerOpen(false); + const editor = editorRef.current; + const Trix = window.Trix; + if (!editor || !Trix) return; + + editor.focus(); + if (savedRangeRef.current) { + editor.editor.setSelectedRange(savedRangeRef.current); + savedRangeRef.current = null; + } + openBlockForImage(); + + // Deliberately no href -- see uploadFile. The saved article is a plain + //
either way, so the link would only ever have existed inside + // the editor, where it fights with selecting and captioning the image. + const attachment = new Trix.Attachment({ + url: item.url, + contentType: item.mime_type || contentTypeForUrl(item.url), + filename: item.alt_text || item.file_name, + alt: item.alt_text ?? "", + ...(item.width ? { width: item.width } : {}), + ...(item.height ? { height: item.height } : {}), + // Trix's previewablePattern excludes extension-less URLs, which would + // render a library image as a file stub instead of a preview. + previewable: true, + }); + editor.editor.insertAttachment(attachment); + + if (!item.alt_text) { + setNotice( + `Inserted ${item.file_name}, which has no alt text. Add it in the Media library.`, + ); + } + }, + [openBlockForImage], + ); - // Deliberately no href -- see uploadFile. The saved article is a plain - //
either way, so the link would only ever have existed inside - // the editor, where it fights with selecting and captioning the image. - const attachment = new Trix.Attachment({ - url: item.url, - contentType: item.mime_type || contentTypeForUrl(item.url), - filename: item.alt_text || item.file_name, - alt: item.alt_text ?? "", - ...(item.width ? { width: item.width } : {}), - ...(item.height ? { height: item.height } : {}), - // Trix's previewablePattern excludes extension-less URLs, which would - // render a library image as a file stub instead of a preview. - previewable: true, - }) - editor.editor.insertAttachment(attachment) - - if (!item.alt_text) { - setNotice(`Inserted ${item.file_name}, which has no alt text. Add it in the Media library.`) - } - }, []) + useEffect(() => { + const editor = editorRef.current; + if (!editor) return; + + const handleFileAccept = (event: Event) => { + const Trix = window.Trix; + if (!Trix) return; + event.preventDefault(); + openBlockForImage(); + editor.editor.insertAttachment( + Trix.Attachment.attachmentForFile((event as TrixFileAcceptEvent).file), + ); + }; + + editor.addEventListener("trix-file-accept", handleFileAccept); + return () => { + editor.removeEventListener("trix-file-accept", handleFileAccept); + }; + }, [openBlockForImage]); const openPicker = useCallback(() => { - const editor = editorRef.current + const editor = editorRef.current; // Captured before the modal takes focus; restored on insert. - savedRangeRef.current = editor ? editor.editor.getSelectedRange() : null - setPickerOpen(true) - }, []) + savedRangeRef.current = editor ? editor.editor.getSelectedRange() : null; + setPickerOpen(true); + }, []); // Image selection and drag-to-rearrange. All overlay DOM lives in the wrapper // *outside* Trix's contenteditable so Trix doesn't overwrite it via its @@ -379,66 +454,69 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // WordPress content is still preserved through a save -- it just can no longer // be set from here. useEffect(() => { - const editor = editorRef.current - const wrapper = wrapperRef.current - if (!editor || !wrapper) return + const editor = editorRef.current; + const wrapper = wrapperRef.current; + if (!editor || !wrapper) return; // Drop indicator (a thin horizontal bar shown only during an active drag). - const dropIndicator = document.createElement("div") - dropIndicator.className = "trix-drop-indicator" - dropIndicator.style.display = "none" - wrapper.appendChild(dropIndicator) + const dropIndicator = document.createElement("div"); + dropIndicator.className = "trix-drop-indicator"; + dropIndicator.style.display = "none"; + wrapper.appendChild(dropIndicator); - let activeFigure: HTMLElement | null = null + let activeFigure: HTMLElement | null = null; // True from the pointerdown on a figure until the pointer is released, // whether or not it has travelled far enough to count as a drag yet. - let gestureActive = false + let gestureActive = false; // Per-gesture cleanup set while a drag is in progress so the outer effect's // teardown can abort it on unmount (prevents leaked document listeners and // a stuck `grabbing` cursor). - let activeGestureCleanup: (() => void) | null = null + let activeGestureCleanup: (() => void) | null = null; const selectFigure = (figure: HTMLElement | null) => { if (activeFigure && activeFigure !== figure) { - activeFigure.classList.remove("attachment--selected") + activeFigure.classList.remove("attachment--selected"); } - activeFigure = figure - if (figure) figure.classList.add("attachment--selected") - } + activeFigure = figure; + if (figure) figure.classList.add("attachment--selected"); + }; // ── Drag-to-rearrange ────────────────────────────────────────────────── // A drop position is a *boundary* between top-level blocks, identified by // the index the dragged block would occupy: boundary i sits above block i, // and boundary blocks.length sits below the last one. - type DropTarget = { index: number } + type DropTarget = { index: number }; - const DRAG_THRESHOLD_PX = 5 + const DRAG_THRESHOLD_PX = 5; const boundaryY = (blocks: HTMLElement[], index: number): number => { - const rect = (blocks[index === 0 ? 0 : index - 1]).getBoundingClientRect() - return index === 0 ? rect.top : rect.bottom - } + const rect = blocks[index === 0 ? 0 : index - 1].getBoundingClientRect(); + return index === 0 ? rect.top : rect.bottom; + }; // ── Auto-scroll while dragging ───────────────────────────────────────── // How close to the edge the pointer has to get before the view starts // moving, and the fastest it will go, per frame. - const AUTO_SCROLL_EDGE_PX = 80 - const AUTO_SCROLL_MAX_PX = 22 + const AUTO_SCROLL_EDGE_PX = 80; + const AUTO_SCROLL_MAX_PX = 22; // Whatever actually scrolls the editor: an overflowing ancestor if the page // puts the article in its own pane, otherwise the window. Resolved per drag // rather than once, since the layout an editor sits in can change. const scrollContainer = (): HTMLElement | null => { - let current = editor.parentElement + let current = editor.parentElement; while (current && current !== document.body) { - const overflowY = window.getComputedStyle(current).overflowY - if (/(auto|scroll|overlay)/.test(overflowY) && current.scrollHeight > current.clientHeight) { - return current + const overflowY = window.getComputedStyle(current).overflowY; + if ( + /(auto|scroll|overlay)/.test(overflowY) && + current.scrollHeight > current.clientHeight + ) { + return current; } - current = current.parentElement + current = current.parentElement; } - return null - } + return null; + }; // Where the dragged block would land, as a boundary index -- or null if it // cannot go anywhere from here. @@ -451,61 +529,59 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // dragging clear past its own block and half of the next one, with the // indicator claiming otherwise the entire way. That is the bug this shape // exists to remove: every boundary drawn is now one that moves something. - // - // Which side to look at is decided by the pointer against the block's own - // midpoint, not by nearest-boundary-overall. Nearest alone inverts at the - // ends of the document: dragging the first image upwards has no boundary - // above it, and the closest one anywhere is the gap *below* its neighbour, - // so the image jumped downwards in answer to an upwards drag. Picking the - // side first means an upwards drag either moves the image up or does - // nothing, never the opposite. - const findDropIndex = (clientY: number, fromIndex: number): number | null => { - const blocks = Array.from(editor.children) as HTMLElement[] - if (blocks.length === 0) return null - - const source = fromIndex >= 0 ? blocks[fromIndex] : null - let first = 0 - let last = blocks.length - if (source) { - const rect = source.getBoundingClientRect() - if (clientY < rect.top + rect.height / 2) last = fromIndex - 1 - else first = fromIndex + 2 + const findDropIndex = ( + clientY: number, + fromIndex: number, + originY: number, + ): number | null => { + const blocks = Array.from(editor.children) as HTMLElement[]; + if (blocks.length === 0) return null; + + let first = 0; + let last = blocks.length; + if (fromIndex >= 0) { + if (clientY < originY) last = fromIndex - 1; + else first = fromIndex + 2; } - let best: number | null = null - let bestDist = Infinity + let best: number | null = null; + let bestDist = Infinity; for (let index = first; index <= last; index++) { - const dist = Math.abs(clientY - boundaryY(blocks, index)) + const dist = Math.abs(clientY - boundaryY(blocks, index)); if (dist < bestDist) { - bestDist = dist - best = index + bestDist = dist; + best = index; } } - return best - } + return best; + }; const positionDropIndicator = (target: DropTarget) => { - const wrapperRect = wrapper.getBoundingClientRect() - const editorRect = editor.getBoundingClientRect() - const y = boundaryY(Array.from(editor.children) as HTMLElement[], target.index) - - const editorLeft = editorRect.left - wrapperRect.left - const editorWidth = editorRect.width - const indicatorWidth = Math.min(editorWidth * 0.4, 240) - - dropIndicator.style.display = "block" - dropIndicator.style.top = `${(y - wrapperRect.top).toString()}px` - dropIndicator.style.left = `${(editorLeft + (editorWidth - indicatorWidth) / 2).toString()}px` - dropIndicator.style.width = `${indicatorWidth.toString()}px` - } + const wrapperRect = wrapper.getBoundingClientRect(); + const editorRect = editor.getBoundingClientRect(); + const y = boundaryY( + Array.from(editor.children) as HTMLElement[], + target.index, + ); + + const editorLeft = editorRect.left - wrapperRect.left; + const editorWidth = editorRect.width; + const indicatorWidth = Math.min(editorWidth * 0.4, 240); + + dropIndicator.style.display = "block"; + dropIndicator.style.top = `${(y - wrapperRect.top).toString()}px`; + dropIndicator.style.left = `${(editorLeft + (editorWidth - indicatorWidth) / 2).toString()}px`; + dropIndicator.style.width = `${indicatorWidth.toString()}px`; + }; // The top-level child of the editor that contains this figure. Trix renders // one element per document block, so these are the units a move reorders. const blockContaining = (node: HTMLElement): HTMLElement | null => { - let current: HTMLElement = node - while (current.parentElement && current.parentElement !== editor) current = current.parentElement - return current.parentElement === editor ? current : null - } + let current: HTMLElement = node; + while (current.parentElement && current.parentElement !== editor) + current = current.parentElement; + return current.parentElement === editor ? current : null; + }; // Re-resolve a figure by id. The element captured when a gesture started may // already be detached: selecting an attachment -- which Trix does on the very @@ -513,31 +589,35 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // taken its place. Anything that asks "which block is this image in?" has to // go through here or it gets the answer for a corpse. const liveFigure = (figure: HTMLElement): HTMLElement | null => { - if (figure.isConnected) return figure - const trixId = figure.getAttribute("data-trix-id") - return trixId ? editor.querySelector(`[data-trix-id="${trixId}"]`) : null - } + if (figure.isConnected) return figure; + const trixId = figure.getAttribute("data-trix-id"); + return trixId + ? editor.querySelector(`[data-trix-id="${trixId}"]`) + : null; + }; // Index of the top-level block holding this figure, or -1. const blockIndexOf = (figure: HTMLElement): number => { - const live = liveFigure(figure) - const block = live ? blockContaining(live) : null - return block ? (Array.from(editor.children) as HTMLElement[]).indexOf(block) : -1 - } + const live = liveFigure(figure); + const block = live ? blockContaining(live) : null; + return block + ? (Array.from(editor.children) as HTMLElement[]).indexOf(block) + : -1; + }; // Put an attachment into Trix's "being edited" state -- the state that // shows its caption field and its toolbar. Trix only enters it from a // mousedown of its own, which is no help either when that mousedown's // selection gets reset (see onUp) or after a move has replaced the element. const editAttachmentForFigure = (figure: Element | null | undefined) => { - const trixId = figure?.getAttribute("data-trix-id") - if (!trixId) return + const trixId = figure?.getAttribute("data-trix-id"); + if (!trixId) return; const attachment = editor.editor .getDocument() .getAttachments() - .find((a) => String(a.id) === trixId) - if (attachment) editor.editor.composition.editAttachment(attachment) - } + .find((a) => String(a.id) === trixId); + if (attachment) editor.editor.composition.editAttachment(attachment); + }; // Move the figure's whole block to sit before or after the target block. // @@ -554,32 +634,33 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // fine-grained edit, which for a whole-block move is what the author would // expect to get back anyway. const commitMove = (figure: HTMLElement, target: DropTarget) => { - const liveBlocks = Array.from(editor.children) as HTMLElement[] - const from = blockIndexOf(figure) - if (from < 0) return - if (target.index < 0 || target.index > liveBlocks.length) return + const liveBlocks = Array.from(editor.children) as HTMLElement[]; + const from = blockIndexOf(figure); + if (from < 0) return; + if (target.index < 0 || target.index > liveBlocks.length) return; const serialized = Array.from( - new DOMParser().parseFromString(editor.value, "text/html").body.children, - ) + new DOMParser().parseFromString(editor.value, "text/html").body + .children, + ); // The serialized blocks line up with the rendered ones by index. If that // ever stops holding, bail rather than reorder the wrong block. - if (serialized.length !== liveBlocks.length) return + if (serialized.length !== liveBlocks.length) return; // Where the block lands once it has been lifted out of the list. - let insertAt = target.index - if (from < insertAt) insertAt -= 1 - if (insertAt === from) return + let insertAt = target.index; + if (from < insertAt) insertAt -= 1; + if (insertAt === from) return; - const [moved] = serialized.splice(from, 1) - serialized.splice(insertAt, 0, moved) + const [moved] = serialized.splice(from, 1); + serialized.splice(insertAt, 0, moved); // Take the attachment editor down before swapping the document out from // under it. Its controller holds a reference to the figure it was // installed on; reloading leaves it pointing at a detached element and // Trix's own click handling then throws on the next click on any image // (getRangeOfAttachment on an attachment the document no longer has). - editor.editor.composition.stopEditingAttachment() + editor.editor.composition.stopEditingAttachment(); // Swap the document in under a recorded undo entry, rather than through // loadHTML. loadHTML routes to Editor#loadSnapshot, which replaces the @@ -587,14 +668,17 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // threw away every undo step the author had built up before it. // recordUndoEntry snapshots the current document and selection onto the // stack, and Composition#setDocument then mutates without disturbing it. - const Trix = window.Trix - if (!Trix) return - const movedDocument = Trix.HTMLParser.parse(serialized.map((block) => block.outerHTML).join(""), { - referenceElement: editor, - }).getDocument() - - editor.editor.recordUndoEntry("Move Image") - editor.editor.composition.setDocument(movedDocument) + const Trix = window.Trix; + if (!Trix) return; + const movedDocument = Trix.HTMLParser.parse( + serialized.map((block) => block.outerHTML).join(""), + { + referenceElement: editor, + }, + ).getDocument(); + + editor.editor.recordUndoEntry("Move Image"); + editor.editor.composition.setDocument(movedDocument); // Re-select the image at its new home. Without this every nudge costs the // author the selection -- and with it the toolbar they are clicking -- @@ -609,11 +693,15 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // then on every click on an image throws inside Trix. The alignment // effect above mutates figure classes on each change, so this is a // reachable state, not a theoretical one. - requestAnimationFrame(() => { requestAnimationFrame(() => { - if (!editor.isConnected) return - editAttachmentForFigure(editor.children[insertAt]?.querySelector("[data-trix-id]")) - }) }) - } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!editor.isConnected) return; + editAttachmentForFigure( + editor.children[insertAt]?.querySelector("[data-trix-id]"), + ); + }); + }); + }; // Note the absence of preventDefault on the pointerdown itself. Suppressing // the default is what a drag needs (it stops the browser turning the @@ -622,33 +710,33 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // its caption field and toolbar. So the default is left alone until the // pointer has actually travelled far enough to be a drag. const beginDrag = (figure: HTMLElement, downEvent: PointerEvent) => { - const pointerId = downEvent.pointerId - const startX = downEvent.clientX - const startY = downEvent.clientY - let dragging = false - let captured = false - let dropTarget: DropTarget | null = null + const pointerId = downEvent.pointerId; + const startX = downEvent.clientX; + let startY = downEvent.clientY; + let dragging = false; + let captured = false; + let dropTarget: DropTarget | null = null; // The pointer's last known position, which auto-scroll keeps re-reading: // while the view is moving under a stationary mouse there are no further // pointermove events, but the block under that unchanged position changes // every frame. - let lastClientY = downEvent.clientY - let scroller: HTMLElement | null = null - let autoScrollFrame: number | null = null + let lastClientY = downEvent.clientY; + let scroller: HTMLElement | null = null; + let autoScrollFrame: number | null = null; // Recomputed on every move: Trix re-renders the figure during the drag, // and the indicator must exclude the boundaries around wherever the // block is *now*, or it offers no-op drops again. const updateDropTarget = () => { - const index = findDropIndex(lastClientY, blockIndexOf(figure)) + const index = findDropIndex(lastClientY, blockIndexOf(figure), startY); if (index === null) { - dropIndicator.style.display = "none" - dropTarget = null - return + dropIndicator.style.display = "none"; + dropTarget = null; + return; } - dropTarget = { index } - positionDropIndicator(dropTarget) - } + dropTarget = { index }; + positionDropIndicator(dropTarget); + }; // Scroll the view when the pointer is held near its top or bottom edge, // speeding up the closer to the edge it gets. Without this, a drop target @@ -656,36 +744,40 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // pointer alone, and a long article is taller than the window, so an image // could only ever be moved as far as the visible page. const autoScrollStep = () => { - autoScrollFrame = null - if (!dragging) return + autoScrollFrame = null; + if (!dragging) return; const bounds = scroller ? scroller.getBoundingClientRect() - : { top: 0, bottom: window.innerHeight } - let delta = 0 + : { top: 0, bottom: window.innerHeight }; + let delta = 0; if (lastClientY < bounds.top + AUTO_SCROLL_EDGE_PX) { - delta = -(bounds.top + AUTO_SCROLL_EDGE_PX - lastClientY) + delta = -(bounds.top + AUTO_SCROLL_EDGE_PX - lastClientY); } else if (lastClientY > bounds.bottom - AUTO_SCROLL_EDGE_PX) { - delta = lastClientY - (bounds.bottom - AUTO_SCROLL_EDGE_PX) + delta = lastClientY - (bounds.bottom - AUTO_SCROLL_EDGE_PX); } if (delta !== 0) { - const speed = Math.min(AUTO_SCROLL_MAX_PX, Math.abs(delta) / AUTO_SCROLL_EDGE_PX * AUTO_SCROLL_MAX_PX) - const by = Math.sign(delta) * Math.max(2, speed) - if (scroller) scroller.scrollTop += by - else window.scrollBy(0, by) + const speed = Math.min( + AUTO_SCROLL_MAX_PX, + (Math.abs(delta) / AUTO_SCROLL_EDGE_PX) * AUTO_SCROLL_MAX_PX, + ); + const by = Math.sign(delta) * Math.max(2, speed); + if (scroller) scroller.scrollTop += by; + else window.scrollBy(0, by); + startY -= by; // The document just moved under the pointer, so the boundary the // pointer now sits at is a different one. - updateDropTarget() + updateDropTarget(); } // Keep polling even when standing still, so pushing back into the edge // zone resumes scrolling without needing a fresh mousemove. - autoScrollFrame = requestAnimationFrame(autoScrollStep) - } + autoScrollFrame = requestAnimationFrame(autoScrollStep); + }; const enterDragMode = () => { - dragging = true + dragging = true; // Take ownership of the pointer stream for the rest of the gesture. // This is the whole reason the gesture is on pointer events: with plain @@ -700,81 +792,82 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // retargets the compatibility mousedown too, which is the event Trix // selects the attachment from. try { - wrapper.setPointerCapture(pointerId) - captured = true + wrapper.setPointerCapture(pointerId); + captured = true; } catch { // The pointer is already gone (released between the move and here). // Nothing to hold; the gesture ends at the next up or cancel. } - liveFigure(figure)?.classList.add("attachment--dragging") - document.body.style.cursor = "grabbing" + liveFigure(figure)?.classList.add("attachment--dragging"); + document.body.style.cursor = "grabbing"; // Drop whatever text selection the un-prevented pointerdown started, so // the drag doesn't paint a selection highlight across the article -- and // so there is no selection left for the browser to want to drag. - window.getSelection()?.removeAllRanges() - scroller = scrollContainer() - autoScrollFrame = requestAnimationFrame(autoScrollStep) - } + window.getSelection()?.removeAllRanges(); + scroller = scrollContainer(); + autoScrollFrame = requestAnimationFrame(autoScrollStep); + }; const onMove = (moveEvent: PointerEvent) => { - if (moveEvent.pointerId !== pointerId) return - lastClientY = moveEvent.clientY + if (moveEvent.pointerId !== pointerId) return; + lastClientY = moveEvent.clientY; if (!dragging) { - const dx = Math.abs(moveEvent.clientX - startX) - const dy = Math.abs(moveEvent.clientY - startY) - if (dx < DRAG_THRESHOLD_PX && dy < DRAG_THRESHOLD_PX) return - enterDragMode() + const dx = Math.abs(moveEvent.clientX - startX); + const dy = Math.abs(moveEvent.clientY - startY); + if (dx < DRAG_THRESHOLD_PX && dy < DRAG_THRESHOLD_PX) return; + enterDragMode(); } // Now that this is a drag, keep the browser from extending a text // selection under the pointer. - moveEvent.preventDefault() - updateDropTarget() - } + moveEvent.preventDefault(); + updateDropTarget(); + }; // Idempotent state-restorer, invoked from onUp on normal release, from // onCancel if the browser takes the pointer away, AND from the outer // effect's cleanup if the component unmounts mid-drag. const cleanup = () => { - gestureActive = false - dragging = false + gestureActive = false; + dragging = false; if (autoScrollFrame !== null) { - cancelAnimationFrame(autoScrollFrame) - autoScrollFrame = null + cancelAnimationFrame(autoScrollFrame); + autoScrollFrame = null; } - document.removeEventListener("pointermove", onMove) - document.removeEventListener("pointerup", onUp) - document.removeEventListener("pointercancel", onCancel) + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onUp); + document.removeEventListener("pointercancel", onCancel); if (captured) { - captured = false - if (wrapper.hasPointerCapture(pointerId)) wrapper.releasePointerCapture(pointerId) + captured = false; + if (wrapper.hasPointerCapture(pointerId)) + wrapper.releasePointerCapture(pointerId); } - document.body.style.cursor = "" - liveFigure(figure)?.classList.remove("attachment--dragging") - dropIndicator.style.display = "none" - } + document.body.style.cursor = ""; + liveFigure(figure)?.classList.remove("attachment--dragging"); + dropIndicator.style.display = "none"; + }; // The pointer was taken away mid-gesture -- a touch turning into a scroll, // the window losing the device. Abandon the move rather than committing to // wherever the indicator happened to be. const onCancel = (cancelEvent: PointerEvent) => { - if (cancelEvent.pointerId !== pointerId) return - activeGestureCleanup = null - cleanup() - } + if (cancelEvent.pointerId !== pointerId) return; + activeGestureCleanup = null; + cleanup(); + }; const onUp = (upEvent: PointerEvent) => { - if (upEvent.pointerId !== pointerId) return - activeGestureCleanup = null + if (upEvent.pointerId !== pointerId) return; + activeGestureCleanup = null; // Read before cleanup, which clears `dragging` to stop the auto-scroll // loop -- reading after it would make every drop look like a click. - const wasDragging = dragging - const releasedOn = dropTarget - cleanup() + const wasDragging = dragging; + const releasedOn = dropTarget; + cleanup(); if (wasDragging && releasedOn) { - commitMove(figure, releasedOn) + commitMove(figure, releasedOn); } else if (!wasDragging) { - selectFigure(figure) + selectFigure(figure); // Re-assert Trix's own attachment selection. Trix makes it on // mousedown, but when the click is what focuses the editor in the // first place, the focus that follows resets the selection and takes @@ -782,20 +875,20 @@ function TrixEditor({ value, onChange }: TrixEditorProps) { // an image in a freshly loaded editor appeared to do nothing, and it // took a second click to get at Remove or the move buttons. Trix // ignores this when the attachment is already the one being edited. - editAttachmentForFigure(figure) + editAttachmentForFigure(figure); } - } + }; - gestureActive = true + gestureActive = true; // Listening on the document rather than on the capture target: captured // events are retargeted to the wrapper but still bubble from there, so one // set of listeners covers both halves of the gesture (before the threshold, // when there is no capture, and after it, when there is). - document.addEventListener("pointermove", onMove) - document.addEventListener("pointerup", onUp) - document.addEventListener("pointercancel", onCancel) - activeGestureCleanup = cleanup - } + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onUp); + document.addEventListener("pointercancel", onCancel); + activeGestureCleanup = cleanup; + }; // Trix's own attachment chrome: the caption (static
, and the //