Skip to content

Commit

Permalink
Add image resize functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
daveearley committed Oct 15, 2024
1 parent e266334 commit c7ef689
Show file tree
Hide file tree
Showing 3 changed files with 350 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {RichTextEditor, useRichTextEditorContext} from "@mantine/tiptap";
import {useCallback, useState} from "react";
import {t} from "@lingui/macro";
import {IconPhotoPlus} from "@tabler/icons-react";
import {Button, Group, Modal, Portal, TextInput} from "@mantine/core";

export const InsertImageControl = () => {
const editor = useRichTextEditorContext();
const [isModalOpen, setModalOpen] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [imageError, setImageError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

// Function to validate if the URL is an actual image
const checkImageExists = (url: string) => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(true);
img.onerror = () => reject(false);
img.src = url;
});
};

const handleImageInsert = useCallback(async () => {
setLoading(true);
try {
await checkImageExists(imageUrl);
if (editor) {
editor.editor!.commands.setImage({src: imageUrl});
}
setModalOpen(false);
setImageError(null);
setImageUrl('');
} catch {
setImageError(t`Please enter a valid image URL that points to an image.`);
} finally {
setLoading(false);
}
}, [editor, imageUrl]);

return (
<>
<RichTextEditor.Control
onClick={() => setModalOpen(true)}
aria-label="Insert star emoji"
title="Insert star emoji"
>
<IconPhotoPlus stroke={1.5} size="1rem"/>
</RichTextEditor.Control>

<Portal>
<Modal
opened={isModalOpen}
onClose={() => setModalOpen(false)}
title={t`Insert Image`}
>
<TextInput
label={t`Image URL`}
placeholder="https://example.com/image.jpg"
value={imageUrl}
onChange={(event) => setImageUrl(event.currentTarget.value)}
error={imageError}
/>
<Group mt="md">
<Button onClick={handleImageInsert} loading={loading}>
{t`Insert Image`}
</Button>
</Group>
</Modal>
</Portal>
</>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import {NodeViewProps} from '@tiptap/core';
import Image from '@tiptap/extension-image';

/**
* Adapted from https://github.com/bae-sh/tiptap-extension-resize-image/blob/main/lib/imageResize.ts
*/
export const ImageResize = Image.extend({
addAttributes() {
return {
src: {
default: null,
},
alt: {
default: null,
},
style: {
default: 'width: 100%; height: auto; cursor: pointer;',
parseHTML: (element: HTMLElement) => {
const width = element.getAttribute('width');
return width
? `width: ${width}px; height: auto; cursor: pointer;`
: `${element.style.cssText}`;
},
},
title: {
default: null,
},
loading: {
default: null,
},
srcset: {
default: null,
},
sizes: {
default: null,
},
crossorigin: {
default: null,
},
usemap: {
default: null,
},
ismap: {
default: null,
},
width: {
default: null,
},
height: {
default: null,
},
referrerpolicy: {
default: null,
},
longdesc: {
default: null,
},
decoding: {
default: null,
},
class: {
default: null,
},
id: {
default: null,
},
name: {
default: null,
},
draggable: {
default: true,
},
tabindex: {
default: null,
},
'aria-label': {
default: null,
},
'aria-labelledby': {
default: null,
},
'aria-describedby': {
default: null,
},
};
},
addNodeView() {
return ({node, editor, getPos}: NodeViewProps) => {
const {
view,
options: {editable},
} = editor;
const {style} = node.attrs;
const $wrapper: HTMLDivElement = document.createElement('div');
const $container: HTMLDivElement = document.createElement('div');
const $img: HTMLImageElement = document.createElement('img');
const iconStyle = 'width: 24px; height: 24px; cursor: pointer; margin-bottom: 0;';

const dispatchNodeView = () => {
if (typeof getPos === 'function') {
const newAttrs = {
...node.attrs,
style: `${$img.style.cssText}`,
};
view.dispatch(view.state.tr.setNodeMarkup(getPos(), null, newAttrs));
}
};

const paintPositionController = () => {
const $positionController: HTMLDivElement = document.createElement('div');

const $leftController: HTMLImageElement = document.createElement('img');
const $centerController: HTMLImageElement = document.createElement('img');
const $rightController: HTMLImageElement = document.createElement('img');

const controllerMouseOver = (e: MouseEvent) => {
(e.target as HTMLElement).style.opacity = '0.3';
};

const controllerMouseOut = (e: MouseEvent) => {
(e.target as HTMLElement).style.opacity = '1';
};

$positionController.setAttribute(
'style',
'position: absolute; top: 0%; left: 50%; width: 100px; height: 25px; z-index: 999; background-color: rgba(255, 255, 255, 0.7); border-radius: 4px; border: 2px solid #6C6C6C; cursor: pointer; transform: translate(-50%, -50%); display: flex; justify-content: space-between; align-items: center; padding: 0 10px;',
);

$leftController.setAttribute(
'src',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/format_align_left/default/20px.svg',
);
$leftController.setAttribute('style', iconStyle);
$leftController.addEventListener('mouseover', controllerMouseOver);
$leftController.addEventListener('mouseout', controllerMouseOut);

$centerController.setAttribute(
'src',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/format_align_center/default/20px.svg',
);
$centerController.setAttribute('style', iconStyle);
$centerController.addEventListener('mouseover', controllerMouseOver);
$centerController.addEventListener('mouseout', controllerMouseOut);

$rightController.setAttribute(
'src',
'https://fonts.gstatic.com/s/i/short-term/release/materialsymbolsoutlined/format_align_right/default/20px.svg',
);
$rightController.setAttribute('style', iconStyle);
$rightController.addEventListener('mouseover', controllerMouseOver);
$rightController.addEventListener('mouseout', controllerMouseOut);

$leftController.addEventListener('click', () => {
$img.setAttribute('style', `${$img.style.cssText} margin: 0 auto 0 0;`);
dispatchNodeView();
});
$centerController.addEventListener('click', () => {
$img.setAttribute('style', `${$img.style.cssText} margin: 0 auto;`);
dispatchNodeView();
});
$rightController.addEventListener('click', () => {
$img.setAttribute('style', `${$img.style.cssText} margin: 0 0 0 auto;`);
dispatchNodeView();
});

$positionController.appendChild($leftController);
$positionController.appendChild($centerController);
$positionController.appendChild($rightController);

$container.appendChild($positionController);
};

$wrapper.setAttribute('style', `display: flex;`);
$wrapper.appendChild($container);

$container.setAttribute('style', `${style}`);
$container.appendChild($img);

Object.entries(node.attrs).forEach(([key, value]) => {
if (value === undefined || value === null) return;
$img.setAttribute(key, value as string);
});

if (!editable) return {dom: $img};

const dotsPosition = [
'top: -4px; left: -4px; cursor: nwse-resize;',
'top: -4px; right: -4px; cursor: nesw-resize;',
'bottom: -4px; left: -4px; cursor: nesw-resize;',
'bottom: -4px; right: -4px; cursor: nwse-resize;',
];

let isResizing = false;
let startX: number, startWidth: number;

$container.addEventListener('click', () => {
// Remove remaining dots and position controller
if ($container.childElementCount > 3) {
for (let i = 0; i < 5; i++) {
$container.removeChild($container.lastChild as Node);
}
}

paintPositionController();

$container.setAttribute(
'style',
`position: relative; border: 1px dashed #6C6C6C; ${style} cursor: pointer;`,
);

Array.from({length: 4}, (_, index) => {
const $dot: HTMLDivElement = document.createElement('div');
$dot.setAttribute(
'style',
`position: absolute; width: 9px; height: 9px; border: 1.5px solid #6C6C6C; border-radius: 50%; ${dotsPosition[index]}`,
);

$dot.addEventListener('mousedown', (e: MouseEvent) => {
e.preventDefault();
isResizing = true;
startX = e.clientX;
startWidth = $container.offsetWidth;

const onMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const deltaX = index % 2 === 0 ? -(e.clientX - startX) : e.clientX - startX;
const newWidth = startWidth + deltaX;

$container.style.width = newWidth + 'px';
$img.style.width = newWidth + 'px';
};

const onMouseUp = () => {
if (isResizing) {
isResizing = false;
}
dispatchNodeView();

document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};

document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
$container.appendChild($dot);
});
});

document.addEventListener('click', (e: MouseEvent) => {
const $target = e.target as HTMLElement;
const isClickInside = $container.contains($target) || $target.style.cssText === iconStyle;

if (!isClickInside) {
const containerStyle = $container.getAttribute('style');
const newStyle = containerStyle?.replace('border: 1px dashed #6C6C6C;', '');
$container.setAttribute('style', newStyle as string);

if ($container.childElementCount > 3) {
for (let i = 0; i < 5; i++) {
$container.removeChild($container.lastChild as Node);
}
}
}
});

return {
dom: $wrapper,
};
};
},
});
Loading

0 comments on commit c7ef689

Please sign in to comment.