Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions assets/css/milkdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,18 @@
@import "@milkdown/crepe/theme/common/latex.css";
@import "@milkdown/crepe/theme/frame.css";

/* Image alt text input */
.milkdown .milkdown-image-block > .image-alt-input {
display: block;
width: 100%;
text-align: center;
color: var(--crepe-color-on-background);
margin: 4px auto;
font-family: var(--crepe-font-default);
font-size: 14px;
padding: 4px 8px;
}

/* Unwrap '@milkdown/crepe/theme/common/reset.css' and remove some styling */
.milkdown {
position: relative;
Expand Down
114 changes: 103 additions & 11 deletions assets/js/milkdown.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Crepe } from "@milkdown/crepe";
import { $nodeSchema } from "@milkdown/utils";
import { $nodeSchema, $prose } from "@milkdown/utils";
import { Plugin, PluginKey } from "@milkdown/prose/state";

const csrfToken = document
.querySelector("meta[name='csrf-token']")
Expand All @@ -23,7 +24,7 @@ const blockEditOpts = {
const uploadUrl = new URL("upload-image", window.location.href).href;

// Create custom image block schema plugin
// Reuses caption input as alt text to get `[alt](url)` format for markdown
// Outputs `![alt](url "caption")` format for markdown
// https://github.com/Milkdown/milkdown/blob/main/packages/components/src/image-block/schema.ts
const customImageBlockPlugin = $nodeSchema("image-block", () => ({
inline: false,
Expand All @@ -36,6 +37,7 @@ const customImageBlockPlugin = $nodeSchema("image-block", () => ({
priority: 100,
attrs: {
src: { default: "", validate: "string" },
alt: { default: "", validate: "string" },
caption: { default: "", validate: "string" },
ratio: { default: 1, validate: "number" },
},
Expand All @@ -48,6 +50,7 @@ const customImageBlockPlugin = $nodeSchema("image-block", () => ({

return {
src: dom.getAttribute("src") || "",
alt: dom.getAttribute("alt") || "",
caption: dom.getAttribute("caption") || "",
ratio: 1,
};
Expand All @@ -62,32 +65,121 @@ const customImageBlockPlugin = $nodeSchema("image-block", () => ({
match: ({ type }) => type === "image-block",
runner: (state, node, type) => {
const src = node.url;
const caption = node.alt;
const ratio = 1; // Always keep ratio as 1
const alt = node.alt;
const caption = node.title;

state.addNode(type, {
src,
alt,
caption,
ratio,
ratio: 1,
});
},
},

// Custom markdown output - standard format
// Custom markdown output - ![alt](url "caption")
toMarkdown: {
match: (node) => node.type.name === "image-block",
runner: (state, node) => {
state.openNode("paragraph");
state.addNode("image", undefined, undefined, {
title: "",
title: node.attrs.caption,
url: node.attrs.src,
alt: node.attrs.caption,
alt: node.attrs.alt,
});
state.closeNode();
},
},
}));

// Plugin to add alt text input to image blocks in the editor
const imageAltPlugin = $prose(() => {
return new Plugin({
key: new PluginKey("image-alt-input"),
view(editorView) {
const managedBlocks = new WeakMap();

const sync = () => {
const blocks = editorView.dom.querySelectorAll(".milkdown-image-block");

for (const block of blocks) {
// Already managed — just sync value when not focused
const altInput = managedBlocks.get(block);
if (altInput && block.contains(altInput)) {
if (document.activeElement !== altInput) {
try {
const pos = editorView.posAtDOM(block, 0);
const node = editorView.state.doc.nodeAt(pos);
if (node) {
altInput.value = node.attrs.alt || "";
}
} catch {
/* position lookup can fail during transitions */
}
}
continue;
}

// Create alt input
const input = document.createElement("input");
input.className = "image-alt-input";
input.placeholder = "Enter image alt";
input.type = "text";

// Set initial value
try {
const pos = editorView.posAtDOM(block, 0);
const node = editorView.state.doc.nodeAt(pos);
if (node) {
input.value = node.attrs.alt || "";
}
} catch {
/* ignore */
}

// Save on blur
const saveValue = () => {
try {
const pos = editorView.posAtDOM(block, 0);
const node = editorView.state.doc.nodeAt(pos);
if (node && node.type.name === "image-block") {
const tr = editorView.state.tr.setNodeMarkup(pos, undefined, {
...node.attrs,
alt: input.value,
});
editorView.dispatch(tr);
}
} catch {
/* ignore */
}
};

input.addEventListener("blur", saveValue);
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
input.blur();
}
e.stopPropagation();
});
input.addEventListener("keypress", (e) => e.stopPropagation());
input.addEventListener("input", (e) => e.stopPropagation());

block.appendChild(input);
managedBlocks.set(block, input);
}
};

return {
update() {
sync();
},
destroy() {},
};
},
});
});

const imageBlockOpts = {
onUpload: async (file) => {
// Create FormData for file upload
Expand All @@ -110,7 +202,7 @@ const imageBlockOpts = {

throw new Error(`Upload failed: ${response.statusText}`);
},
blockCaptionPlaceholderText: "Enter image alt",
blockCaptionPlaceholderText: "Enter image caption",
};

const MilkdownEditor = (element) => {
Expand Down Expand Up @@ -142,8 +234,8 @@ const MilkdownEditor = (element) => {
},
});

// Add custom image block plugin before creating
crepe.editor.use(customImageBlockPlugin);
// Add custom image block plugins before creating
crepe.editor.use(customImageBlockPlugin).use(imageAltPlugin);

crepe.create().then(() => {
crepe.on((listener) => {
Expand Down
4 changes: 3 additions & 1 deletion lib/literature/components/image_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ defmodule Literature.ImageComponent do
def parse_image_tag({"img", _, _} = tag, img_str \\ nil) do
case get_img_size(tag) do
{width, height} ->
caption = find_img_attribute(tag, "title") || find_img_attribute(tag, "caption")

~s"""
<picture>
<source srcset="#{load_srcset(:jpg, find_img_attribute(tag, "src"))}"/>
<source srcset="#{load_srcset(:webp, find_img_attribute(tag, "src"))}"/>
<img src="#{find_img_attribute(tag, "src")}" alt="#{find_img_attribute(tag, "alt")}" width="#{width}" height="#{height}" loading="lazy" />
<figcaption style="font-style: italic;";>#{find_img_attribute(tag, "caption")}</figcaption>
<figcaption style="font-style: italic;">#{caption}</figcaption>
</picture>
"""

Expand Down
Loading