Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add image extension to rich text editor #252

Merged
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"@stripe/stripe-js": "^1.54.1",
"@tabler/icons-react": "^2.44.0",
"@tanstack/react-query": "5.52.2",
"@tiptap/extension-image": "^2.8.0",
"@tiptap/extension-link": "^2.1.13",
"@tiptap/extension-text-align": "^2.1.13",
"@tiptap/extension-underline": "^2.1.13",
Expand Down
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,
};
};
},
});
8 changes: 8 additions & 0 deletions frontend/src/components/common/Editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import {useEditor} from "@tiptap/react";
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import TextAlign from '@tiptap/extension-text-align';
import Image from '@tiptap/extension-image';
import React, {useEffect, useState} from "react";
import {InputDescription, InputError, InputLabel} from "@mantine/core";
import classes from "./Editor.module.scss";
import classNames from "classnames";
import {Trans} from "@lingui/macro";
import {InsertImageControl} from "./Controls/InsertImageControl";
import {ImageResize} from "./Extensions/ImageResizeExtension";

interface EditorProps {
onChange: (value: string) => void;
Expand Down Expand Up @@ -40,6 +43,8 @@ export const Editor = ({
Underline,
Link,
TextAlign.configure({types: ['heading', 'paragraph']}),
Image,
ImageResize
],
onUpdate: ({editor}) => {
const html = editor.getHTML();
Expand Down Expand Up @@ -112,6 +117,9 @@ export const Editor = ({
<RichTextEditor.AlignJustify/>
<RichTextEditor.AlignRight/>
</RichTextEditor.ControlsGroup>
<RichTextEditor.ControlsGroup>
<InsertImageControl/>
</RichTextEditor.ControlsGroup>
</>
)}

Expand Down
Loading
Loading