diff --git a/src/lib/extensions/image.ts b/src/lib/extensions/image.ts index 1a0f774..95ca800 100644 --- a/src/lib/extensions/image.ts +++ b/src/lib/extensions/image.ts @@ -13,6 +13,14 @@ export const CustomImage = Image.extend({ src: { default: null, }, + width: { + default: null, + parseHTML: element => element.getAttribute('width'), + renderHTML: attributes => { + if (!attributes.width) return {}; + return { width: attributes.width }; + }, + }, filename: { default: null, parseHTML: element => element.getAttribute('data-filename'), @@ -39,9 +47,16 @@ export const CustomImage = Image.extend({ return ({ node, getPos, editor }) => { const wrapper = document.createElement('div'); // Give it position: relative and flex/inline-block to wrap the image tightly - wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-fit', 'max-w-full'); + wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full'); wrapper.style.display = 'block'; // Block level to not mess up flow wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow + + if (node.attrs.width) { + const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width; + wrapper.style.width = w; + } else { + wrapper.style.width = 'fit-content'; + } const img = document.createElement('img'); Object.entries(node.attrs).forEach(([key, value]) => { @@ -54,6 +69,93 @@ export const CustomImage = Image.extend({ } }); img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default'); + img.style.width = '100%'; + + // Resize handle + const resizer = document.createElement('div'); + // Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging + resizer.style.touchAction = 'none'; + resizer.innerHTML = ''; + resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm'); + resizer.contentEditable = 'false'; + + let isResizing = false; + let startX = 0; + let startWidth = 0; + + const initResize = (clientX: number) => { + isResizing = true; + startX = clientX; + startWidth = wrapper.offsetWidth; + }; + + const doResize = (clientX: number, minW: number) => { + if (!isResizing) return; + const dx = clientX - startX; + const newWidth = Math.max(minW, startWidth + dx); + wrapper.style.width = `${newWidth}px`; + }; + + const stopResize = () => { + if (!isResizing) return; + isResizing = false; + if (typeof getPos === 'function') { + const pos = getPos(); + if (typeof pos === 'number') { + editor.commands.command(({ tr }) => { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + width: wrapper.offsetWidth, + }); + return true; + }); + } + } + }; + + resizer.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + initResize(e.clientX); + + const onMouseMove = (ev: MouseEvent) => { + ev.preventDefault(); + doResize(ev.clientX, 100); + }; + + const onMouseUp = (ev: MouseEvent) => { + ev.preventDefault(); + stopResize(); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + resizer.addEventListener('touchstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + initResize(e.touches[0].clientX); + + const onTouchMove = (ev: TouchEvent) => { + ev.preventDefault(); + doResize(ev.touches[0].clientX, 100); + }; + + const onTouchEnd = () => { + // touchend doesn't have touches[0] usually + stopResize(); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('touchcancel', onTouchEnd); + }; + + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + document.addEventListener('touchcancel', onTouchEnd); + }, { passive: false }); // Delete button overlay const overlay = document.createElement('div'); @@ -76,6 +178,7 @@ export const CustomImage = Image.extend({ overlay.appendChild(deleteBtn); wrapper.appendChild(img); + wrapper.appendChild(resizer); wrapper.appendChild(overlay); return { diff --git a/src/lib/extensions/video.ts b/src/lib/extensions/video.ts index d50c814..bd3fb4b 100644 --- a/src/lib/extensions/video.ts +++ b/src/lib/extensions/video.ts @@ -27,6 +27,14 @@ export const Video = Node.create({ src: { default: null, }, + width: { + default: null, + parseHTML: element => element.getAttribute('width'), + renderHTML: attributes => { + if (!attributes.width) return {}; + return { width: attributes.width }; + }, + }, filename: { default: null, parseHTML: element => element.getAttribute('data-filename'), @@ -89,8 +97,16 @@ export const Video = Node.create({ addNodeView() { return ({ node, getPos, editor }) => { const wrapper = document.createElement('div'); - wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-full', 'max-w-full'); + wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'max-w-full'); wrapper.style.display = 'block'; + wrapper.style.marginInline = 'auto'; // Center if desired + + if (node.attrs.width) { + const w = typeof node.attrs.width === 'number' ? `${node.attrs.width}px` : node.attrs.width; + wrapper.style.width = w; + } else { + wrapper.style.width = '100%'; + } const video = document.createElement('video'); Object.entries(node.attrs).forEach(([key, value]) => { @@ -107,6 +123,93 @@ export const Video = Node.create({ video.setAttribute('controls', 'true'); } video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden'); + video.style.width = '100%'; + + // Resize handle + const resizer = document.createElement('div'); + // Adding style.touchAction = 'none' to prevent mobile browser scrolling when dragging + resizer.style.touchAction = 'none'; + resizer.innerHTML = ''; + resizer.classList.add('absolute', 'bottom-2', 'right-2', 'w-6', 'h-6', 'bg-black/50', 'hover:bg-black/80', 'backdrop-blur-sm', 'cursor-nwse-resize', 'transition-opacity', 'z-20', 'rounded-md', 'flex', 'items-center', 'justify-center', 'shadow-sm'); + resizer.contentEditable = 'false'; + + let isResizing = false; + let startX = 0; + let startWidth = 0; + + const initResize = (clientX: number) => { + isResizing = true; + startX = clientX; + startWidth = wrapper.offsetWidth; + }; + + const doResize = (clientX: number, minW: number) => { + if (!isResizing) return; + const dx = clientX - startX; + const newWidth = Math.max(minW, startWidth + dx); + wrapper.style.width = `${newWidth}px`; + }; + + const stopResize = () => { + if (!isResizing) return; + isResizing = false; + if (typeof getPos === 'function') { + const pos = getPos(); + if (typeof pos === 'number') { + editor.commands.command(({ tr }) => { + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + width: wrapper.offsetWidth, + }); + return true; + }); + } + } + }; + + resizer.addEventListener('mousedown', (e) => { + // Prevent event from dragging the whole block + e.preventDefault(); + e.stopPropagation(); + initResize(e.clientX); + + const onMouseMove = (ev: MouseEvent) => { + ev.preventDefault(); + doResize(ev.clientX, 200); + }; + + const onMouseUp = (ev: MouseEvent) => { + ev.preventDefault(); + stopResize(); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + }; + + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + resizer.addEventListener('touchstart', (e) => { + e.preventDefault(); + e.stopPropagation(); + initResize(e.touches[0].clientX); + + const onTouchMove = (ev: TouchEvent) => { + ev.preventDefault(); + doResize(ev.touches[0].clientX, 200); + }; + + const onTouchEnd = () => { + stopResize(); + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + document.removeEventListener('touchcancel', onTouchEnd); + }; + + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + document.addEventListener('touchcancel', onTouchEnd); + }, { passive: false }); // Delete button overlay const overlay = document.createElement('div'); @@ -129,6 +232,7 @@ export const Video = Node.create({ overlay.appendChild(deleteBtn); wrapper.appendChild(video); + wrapper.appendChild(resizer); wrapper.appendChild(overlay); return {