UNPKG

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
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;