Skip to content
Merged
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
8 changes: 7 additions & 1 deletion plugins/hyperimage/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"types": "./dist/Node.d.ts",
"default": "./dist/Node.js"
},
"./storage": {
"types": "./dist/storage/index.d.ts",
"default": "./dist/storage/index.js"
},
"./css": {
"default": "./dist/hyperimage.css"
}
Expand All @@ -21,9 +25,11 @@
],
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch --ignore-watch .turbo --ignore-watch dist"
"dev": "tsdown --watch --ignore-watch .turbo --ignore-watch dist",
"test": "vitest run"
},
"devDependencies": {
"fake-indexeddb": "^6.0.0",
"@fujocoded/astrolabe-editor-tree-viewer": "workspace:*",
"@storybook/addon-vitest": "catalog:storybook",
"@tiptap/react": "catalog:",
Expand Down
111 changes: 108 additions & 3 deletions plugins/hyperimage/src/Node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@ import {
type ImageOptions,
} from "@tiptap/extension-image";
import { PasteDropHandler } from "./PasteDropHandler";
import {
defaultStore,
generateSessionId,
removeOrphanedImages,
type ProcessorConfig,
} from "./storage";
import "./hyperimage.css";
import type { HtmlHTMLAttributes } from "react";

const IMAGES_HEARTBEAT_MS = 5 * 60 * 1000;

// TODO: fix once tiptap fixes issues with types https://github.com/ueberdosis/tiptap/issues/6670
type RenderHTMLType = {
Expand All @@ -19,11 +26,46 @@ type RenderHTMLType = {
export type HyperimageOptions = ImageOptions & {
HTMLAttributes: Partial<{
"data-astrolb-type": string;
// TODO: a secret tool that will help us later
"data-astrolb-id": string;
}>;
imageOptions?: Partial<Omit<ProcessorConfig, "scopeId">>;
/**
* Document ID for scoped storage cleanup.
*
* If provided, orphaned images for this document are cleaned up on editor init.
* If not provided, a session ID is auto-generated and images can be cleaned up
* according to age.
*/
documentId?: string;
};

function setUpStorage({
editor,
scopeId,
trackedIds,
activeImageIds,
}: {
editor: Editor;
scopeId: string;
trackedIds: Set<string>;
activeImageIds: string[];
}) {
removeOrphanedImages(defaultStore, scopeId, activeImageIds).catch((err) =>
console.warn("Failed to reconcile storage:", err),
);

const heartbeat = setInterval(() => {
const ids = [...trackedIds];
if (ids.length > 0) {
defaultStore
.refreshLastUsed(ids)
.catch((err) => console.warn("Failed to touch images:", err));
}
}, IMAGES_HEARTBEAT_MS);

editor.on("destroy", () => clearInterval(heartbeat));
}

export const Plugin = ImageExtension.extend<HyperimageOptions>({
name: "hyperimage",

Expand Down Expand Up @@ -85,7 +127,70 @@ export const Plugin = ImageExtension.extend<HyperimageOptions>({
];
},

addStorage() {
return {
trackedIds: new Set<string>(),
scopeId: generateSessionId(),
};
},

onCreate() {
if (this.options.documentId) {
this.storage.scopeId = this.options.documentId;
}

const activeImageIds: string[] = [];
this.editor.state.doc.descendants((node) => {
if (node.type.name === this.name && node.attrs.id) {
activeImageIds.push(node.attrs.id);
this.storage.trackedIds.add(node.attrs.id);
}
});

const editor = this.editor.options.element;
if (editor instanceof HTMLElement) {
editor.setAttribute("data-astrolb-scope-id", this.storage.scopeId);
}

setUpStorage({
editor: this.editor,
scopeId: this.storage.scopeId,
trackedIds: this.storage.trackedIds,
activeImageIds,
});
},

onTransaction({ transaction }) {
if (!transaction.docChanged) return;

const currentIds = new Set<string>();
transaction.doc.descendants((node) => {
if (node.type.name === this.name && node.attrs.id) {
currentIds.add(node.attrs.id);
}
});

const deletedIds = [...this.storage.trackedIds].filter(
(id) => !currentIds.has(id),
);

if (deletedIds.length > 0) {
defaultStore
.deleteMany(deletedIds)
.catch((err) => console.warn("Failed to delete images:", err));
}

this.storage.trackedIds = currentIds;
},

addProseMirrorPlugins() {
return [PasteDropHandler(this.editor)];
return [
PasteDropHandler(this.editor, {
processorConfig: {
...this.options.imageOptions,
scopeId: this.storage.scopeId,
},
}),
];
},
});
93 changes: 48 additions & 45 deletions plugins/hyperimage/src/PasteDropHandler.tsx
Original file line number Diff line number Diff line change
@@ -1,59 +1,61 @@
import { type Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import {
processImageForEditor,
defaultStore,
type ProcessorConfig,
} from "./storage";

const fileToBase64 = (file: File): Promise<string> => {
const { promise, resolve, reject } = Promise.withResolvers<string>();
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => reject(new Error(`Failed to read ${file.name}`));
reader.readAsDataURL(file);
return promise;
};

const imageToFile = async (imageUrl: string): Promise<File> => {
async function imageUrlToFile(imageUrl: string): Promise<File> {
const response = await fetch(imageUrl);
const blob = await response.blob();
const filename = new URL(imageUrl).pathname.split("/").pop() || "image";
return new File([blob], filename, { type: blob.type });
};
}

const isAllowedMimeType = (mimeType: string): boolean => {
function isAllowedMimeType(mimeType: string): boolean {
return mimeType.startsWith("image/");
};
}

const insertImage = async ({
async function insertImage({
editor,
file,
processorConfig,
}: {
editor: Editor;
file: File;
}): Promise<void> => {
const imageId = crypto.randomUUID();
const base64Data = await fileToBase64(file);
processorConfig?: Partial<ProcessorConfig>;
}): Promise<void> {
const processed = await processImageForEditor(
file,
defaultStore,
processorConfig,
);

editor
.chain()
.insertContent({
type: "hyperimage",
attrs: {
src: base64Data,
id: imageId,
src: processed.displaySrc,
...(processed.wasStored && { id: processed.id }),
},
})
.focus()
.scrollIntoView()
.run();
};

/**
* ProseMirror plugin that handles pasting and dropping images
* Converts images to base64 and inserts them as hyperimage nodes
*
* Based on tiptap's file handler plugin:
* https://github.com/ueberdosis/tiptap/blob/develop/packages/extension-file-handler/src/FileHandlePlugin.ts
* https://tiptap.dev/docs/editor/extensions/functionality/filehandler
*/
export const PasteDropHandler = (editor: Editor) => {
}

export interface PasteDropHandlerOptions {
processorConfig?: Partial<ProcessorConfig>;
}

export function PasteDropHandler(
editor: Editor,
options: PasteDropHandlerOptions = {},
) {
const { processorConfig } = options;

return new Plugin({
key: new PluginKey("hyperimage-pasteAndDrop"),

Expand All @@ -72,7 +74,9 @@ export const PasteDropHandler = (editor: Editor) => {
event.preventDefault();
event.stopPropagation();

imageFiles.forEach((file) => insertImage({ editor, file }));
imageFiles.forEach((file) =>
insertImage({ editor, file, processorConfig }),
);
return true;
},

Expand All @@ -93,28 +97,27 @@ export const PasteDropHandler = (editor: Editor) => {
// gifs or webms as they are not copied correctly when moved as files
// and will end up transformed into a PNG. This way, we can instead
// keep the original image type and data.
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, "text/html");
const images = doc.querySelectorAll("img");

// We're specifically "firing and forgetting": rather than block on the
// image being loaded, we will just add it once it is.
// TODO: this will case problems if multiple images load at different times, and
// potentially if the user changes their current position.
images.forEach(async (image) => {
const file = await imageToFile(image.src);
insertImage({ editor, file });
const parsedDoc = new DOMParser().parseFromString(
Comment thread
essential-randomness marked this conversation as resolved.
Outdated
htmlContent,
"text/html",
);
// TODO: this may cause ordering issues with multiple images but it's
// good enough for now
parsedDoc.querySelectorAll("img").forEach(async (image) => {
const file = await imageUrlToFile(image.src);
insertImage({ editor, file, processorConfig });
});
return true;
}

// There was no html content, so we can insert the images directly from the file data
event.preventDefault();
event.stopPropagation();

imageFiles.forEach((file) => insertImage({ editor, file }));
imageFiles.forEach((file) =>
insertImage({ editor, file, processorConfig }),
);
return true;
},
},
});
};
}
Loading