claritykit-svelte
Version:
A comprehensive Svelte component library focused on accessibility, ADHD-optimized design, developer experience, and full SSR compatibility
341 lines (340 loc) • 15.4 kB
JavaScript
import { Node, mergeAttributes } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
export const ImageResizeExtension = Node.create({
name: 'resizableImage',
addOptions() {
return {
HTMLAttributes: {
class: 'resizable-image',
},
allowResize: true,
allowAlignment: true,
allowCaption: true,
maxWidth: 800,
minWidth: 100,
resizeHandles: ['se'], // Southeast handle by default
};
},
group: 'block',
content: 'text*',
draggable: true,
addAttributes() {
return {
src: {
default: null,
parseHTML: element => element.querySelector('img')?.getAttribute('src'),
renderHTML: attributes => {
if (!attributes.src)
return {};
return {};
},
},
alt: {
default: null,
parseHTML: element => element.querySelector('img')?.getAttribute('alt'),
renderHTML: attributes => {
if (!attributes.alt)
return {};
return {};
},
},
width: {
default: null,
parseHTML: element => {
const img = element.querySelector('img');
const width = img?.getAttribute('width') || img?.style.width;
return width ? parseInt(width) : null;
},
renderHTML: attributes => {
if (!attributes.width)
return {};
return {};
},
},
height: {
default: null,
parseHTML: element => {
const img = element.querySelector('img');
const height = img?.getAttribute('height') || img?.style.height;
return height ? parseInt(height) : null;
},
renderHTML: attributes => {
if (!attributes.height)
return {};
return {};
},
},
alignment: {
default: 'center',
parseHTML: element => {
const container = element.querySelector('.image-container');
if (container?.classList.contains('align-left'))
return 'left';
if (container?.classList.contains('align-right'))
return 'right';
return 'center';
},
renderHTML: attributes => {
return {};
},
},
caption: {
default: '',
parseHTML: element => {
const caption = element.querySelector('.image-caption');
return caption?.textContent || '';
},
renderHTML: attributes => {
return {};
},
},
};
},
parseHTML() {
return [
{
tag: 'div.resizable-image',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const { src, alt, width, height, alignment, caption } = node.attrs;
const imageStyle = {
width: width ? `${width}px` : 'auto',
height: height ? `${height}px` : 'auto',
maxWidth: '100%',
};
return [
'div',
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
'data-src': src,
'data-alignment': alignment,
class: `resizable-image align-${alignment}`,
}),
[
'div',
{ class: 'image-container' },
[
'img',
{
src,
alt: alt || '',
style: Object.entries(imageStyle)
.map(([key, value]) => `${key}: ${value}`)
.join('; '),
draggable: false,
},
],
this.options.allowResize && [
'div',
{ class: 'resize-handles' },
...this.options.resizeHandles.map(handle => [
'div',
{
class: `resize-handle resize-handle-${handle}`,
'data-handle': handle,
},
]),
],
],
this.options.allowAlignment && [
'div',
{ class: 'image-toolbar' },
[
'button',
{
class: `alignment-btn ${alignment === 'left' ? 'active' : ''}`,
'data-alignment': 'left',
title: 'Align left',
},
'⬅️',
],
[
'button',
{
class: `alignment-btn ${alignment === 'center' ? 'active' : ''}`,
'data-alignment': 'center',
title: 'Align center',
},
'↔️',
],
[
'button',
{
class: `alignment-btn ${alignment === 'right' ? 'active' : ''}`,
'data-alignment': 'right',
title: 'Align right',
},
'➡️',
],
],
this.options.allowCaption && [
'div',
{
class: 'image-caption',
contenteditable: 'true',
'data-placeholder': 'Add a caption...',
},
caption || '',
],
].filter(Boolean);
},
addCommands() {
return {
insertResizableImage: (options) => ({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
updateImageSize: (src, width, height) => ({ tr, state }) => {
const { doc } = state;
let updated = false;
doc.descendants((node, pos) => {
if (node.type === this.type && node.attrs.src === src) {
const newAttrs = {
...node.attrs,
width,
...(height && { height })
};
tr.setNodeMarkup(pos, undefined, newAttrs);
updated = true;
return false;
}
});
return updated;
},
updateImageAlignment: (src, alignment) => ({ tr, state }) => {
const { doc } = state;
let updated = false;
doc.descendants((node, pos) => {
if (node.type === this.type && node.attrs.src === src) {
const newAttrs = { ...node.attrs, alignment };
tr.setNodeMarkup(pos, undefined, newAttrs);
updated = true;
return false;
}
});
return updated;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('imageResize'),
state: {
init() {
return {
resizing: null,
};
},
apply(tr, value) {
return value;
},
},
props: {
handleDOMEvents: {
mousedown: (view, event) => {
const target = event.target;
// Handle resize
if (target.classList.contains('resize-handle')) {
event.preventDefault();
event.stopPropagation();
const handle = target.getAttribute('data-handle');
const imageContainer = target.closest('.resizable-image');
const img = imageContainer.querySelector('img');
const src = img.src;
const startX = event.clientX;
const startY = event.clientY;
const startWidth = img.offsetWidth;
const startHeight = img.offsetHeight;
const plugin = this;
const pluginState = plugin.getState(view.state);
pluginState.resizing = {
src,
startX,
startY,
startWidth,
startHeight,
handle,
};
const handleMouseMove = (e) => {
if (!pluginState.resizing)
return;
const deltaX = e.clientX - pluginState.resizing.startX;
const deltaY = e.clientY - pluginState.resizing.startY;
let newWidth = pluginState.resizing.startWidth;
let newHeight = pluginState.resizing.startHeight;
// Calculate new dimensions based on handle
switch (pluginState.resizing.handle) {
case 'se':
newWidth = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, pluginState.resizing.startWidth + deltaX));
newHeight = (newWidth / pluginState.resizing.startWidth) * pluginState.resizing.startHeight;
break;
case 'sw':
newWidth = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, pluginState.resizing.startWidth - deltaX));
newHeight = (newWidth / pluginState.resizing.startWidth) * pluginState.resizing.startHeight;
break;
case 'ne':
newWidth = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, pluginState.resizing.startWidth + deltaX));
newHeight = (newWidth / pluginState.resizing.startWidth) * pluginState.resizing.startHeight;
break;
case 'nw':
newWidth = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, pluginState.resizing.startWidth - deltaX));
newHeight = (newWidth / pluginState.resizing.startWidth) * pluginState.resizing.startHeight;
break;
case 'e':
newWidth = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, pluginState.resizing.startWidth + deltaX));
break;
case 'w':
newWidth = Math.max(this.options.minWidth, Math.min(this.options.maxWidth, pluginState.resizing.startWidth - deltaX));
break;
}
// Update image size visually
img.style.width = `${newWidth}px`;
if (newHeight) {
img.style.height = `${newHeight}px`;
}
};
const handleMouseUp = () => {
if (!pluginState.resizing)
return;
const finalWidth = img.offsetWidth;
const finalHeight = img.offsetHeight;
// Update the node in the document
view.dispatch(view.state.tr.setNodeMarkup(view.state.selection.from, undefined, {
...view.state.selection.$from.parent.attrs,
width: finalWidth,
height: finalHeight,
}));
this.options.onResize?.(finalWidth, finalHeight, pluginState.resizing.src);
pluginState.resizing = null;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return true;
}
// Handle alignment buttons
if (target.classList.contains('alignment-btn')) {
event.preventDefault();
event.stopPropagation();
const alignment = target.getAttribute('data-alignment');
const imageContainer = target.closest('.resizable-image');
const src = imageContainer.getAttribute('data-src');
this.editor.commands.updateImageAlignment(src, alignment);
this.options.onAlignmentChange?.(alignment, src);
return true;
}
return false;
},
},
},
}),
];
},
});
export default ImageResizeExtension;