feat: Contentstack <-> ImageVault integration
This commit is contained in:
31
rte/components/EmbedBtn.tsx
Normal file
31
rte/components/EmbedBtn.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React, { PropsWithChildren } from 'react';
|
||||
import { Tooltip } from '@contentstack/venus-components';
|
||||
|
||||
type EmbedBtnProps = PropsWithChildren & {
|
||||
content: string;
|
||||
onClick: (e: unknown) => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export default function EmbedBtn({
|
||||
content,
|
||||
title,
|
||||
onClick,
|
||||
children,
|
||||
}: EmbedBtnProps) {
|
||||
return (
|
||||
<Tooltip position="bottom" content={content}>
|
||||
<button
|
||||
id={title}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
213
rte/components/ImageEditModal.tsx
Normal file
213
rte/components/ImageEditModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, { useState, useEffect, ChangeEvent } from 'react';
|
||||
import {
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalHeader,
|
||||
ButtonGroup,
|
||||
Button,
|
||||
Field,
|
||||
FieldLabel,
|
||||
TextInput,
|
||||
Select,
|
||||
} from '@contentstack/venus-components';
|
||||
import { Path } from 'slate';
|
||||
|
||||
import type {
|
||||
IRteParam,
|
||||
IRteElementType,
|
||||
} from '@contentstack/app-sdk/dist/src/RTE/types';
|
||||
import type { InsertResponse } from "~/types/imagevault";
|
||||
|
||||
enum DropdownValues {
|
||||
center = "center",
|
||||
left = "left",
|
||||
right = "right",
|
||||
none = "none",
|
||||
}
|
||||
|
||||
type DropDownItem = {
|
||||
label: string;
|
||||
value: DropdownValues;
|
||||
type: string;
|
||||
};
|
||||
|
||||
const dropdownList: DropDownItem[] = [
|
||||
{
|
||||
label: "None",
|
||||
value: DropdownValues.none,
|
||||
type: "select",
|
||||
},
|
||||
{
|
||||
label: "Center",
|
||||
value: DropdownValues.center,
|
||||
type: "select",
|
||||
},
|
||||
{
|
||||
label: "Left",
|
||||
value: DropdownValues.left,
|
||||
type: "select",
|
||||
},
|
||||
{
|
||||
label: "Right",
|
||||
value: DropdownValues.right,
|
||||
type: "select",
|
||||
},
|
||||
];
|
||||
|
||||
type ImageEditModalProps = {
|
||||
element: IRteElementType & {
|
||||
attrs: InsertResponse;
|
||||
};
|
||||
rte: IRteParam;
|
||||
closeModal: () => void;
|
||||
path: Path;
|
||||
};
|
||||
|
||||
export default function ImageEditModal({
|
||||
element,
|
||||
closeModal,
|
||||
path,
|
||||
rte,
|
||||
}: ImageEditModalProps) {
|
||||
const [alignment, setAlignment] = useState<DropDownItem>({
|
||||
label: "None",
|
||||
value: DropdownValues.none,
|
||||
type: "select",
|
||||
});
|
||||
const [altText, setAltText] = useState("");
|
||||
const [caption, setCaption] = useState("");
|
||||
|
||||
const assetUrl = element.attrs.MediaConversions[0].Url;
|
||||
|
||||
useEffect(() => {
|
||||
if (element.attrs.Metadata && element.attrs.Metadata.length) {
|
||||
const altText = element.attrs.Metadata.find((meta) =>
|
||||
meta.Name.includes("AltText_")
|
||||
)?.Value;
|
||||
|
||||
const caption = element.attrs.Metadata.find((meta) =>
|
||||
meta.Name.includes("Title_")
|
||||
)?.Value;
|
||||
|
||||
setAltText(altText ?? "");
|
||||
setCaption(caption ?? "");
|
||||
}
|
||||
}, [element.attrs.Metadata]);
|
||||
|
||||
function handleSave() {
|
||||
let newStyle;
|
||||
|
||||
switch (alignment.value) {
|
||||
case DropdownValues.center:
|
||||
case DropdownValues.left:
|
||||
case DropdownValues.right:
|
||||
newStyle = {
|
||||
textAlign: alignment.value,
|
||||
maxWidth: element.attrs.width
|
||||
? `${element.attrs.width}px`
|
||||
: undefined,
|
||||
};
|
||||
break;
|
||||
case DropdownValues.none:
|
||||
default:
|
||||
newStyle = {};
|
||||
break;
|
||||
}
|
||||
|
||||
const metaData = element.attrs.Metadata ?? [];
|
||||
|
||||
const newMetadata = metaData.map((meta) => {
|
||||
if (meta.Name.includes("AltText_")) {
|
||||
return { ...meta, Value: altText };
|
||||
}
|
||||
if (meta.Name.includes("Title_")) {
|
||||
return { ...meta, Value: caption };
|
||||
}
|
||||
return meta;
|
||||
});
|
||||
|
||||
rte._adv.Transforms?.setNodes<IRteElementType>(
|
||||
rte._adv.editor,
|
||||
{
|
||||
attrs: {
|
||||
...element.attrs,
|
||||
Metadata: newMetadata,
|
||||
position: alignment.value,
|
||||
style: { ...element.attrs.style, ...newStyle },
|
||||
},
|
||||
},
|
||||
{ at: path }
|
||||
);
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader title="Update image" closeModal={closeModal} />
|
||||
<ModalBody
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: "1rem",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
||||
<img
|
||||
src={assetUrl}
|
||||
alt={altText}
|
||||
height="100%"
|
||||
style={{ maxHeight: "345px" }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Field>
|
||||
<Select
|
||||
selectLabel="Alignment"
|
||||
value={alignment}
|
||||
onChange={(e: DropDownItem) => {
|
||||
setAlignment(e);
|
||||
}}
|
||||
options={dropdownList}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="alt">Alt text</FieldLabel>
|
||||
<TextInput
|
||||
value={altText}
|
||||
placeholder="Alt text for image"
|
||||
name="alt"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setAltText(e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="caption">Caption</FieldLabel>
|
||||
<TextInput
|
||||
value={caption}
|
||||
placeholder="Caption for image..."
|
||||
name="caption"
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setCaption(e.target.value)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<ButtonGroup>
|
||||
<Button onClick={closeModal} buttonType="light">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} icon="SaveWhite">
|
||||
Save
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ModalFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
173
rte/components/ImageElement.tsx
Normal file
173
rte/components/ImageElement.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useRef, useCallback, PropsWithChildren } from 'react';
|
||||
import { Tooltip, Icon, cbModal } from '@contentstack/venus-components';
|
||||
|
||||
import { Resizable } from 're-resizable';
|
||||
import EmbedBtn from './EmbedBtn';
|
||||
import ImageEditModal from './ImageEditModal';
|
||||
|
||||
import type {
|
||||
IRteParam,
|
||||
IRteElementType,
|
||||
} from '@contentstack/app-sdk/dist/src/RTE/types';
|
||||
import type { InsertResponse } from '~/types/imagevault';
|
||||
|
||||
type ImageElementProps = PropsWithChildren & {
|
||||
element: IRteElementType & { attrs: InsertResponse };
|
||||
rte: IRteParam;
|
||||
};
|
||||
export function ImageElement({ children, element, rte }: ImageElementProps) {
|
||||
const assetUrl = element.attrs.MediaConversions[0].Url;
|
||||
const isSelected = rte?.selection?.isSelected();
|
||||
const isFocused = rte?.selection?.isFocused();
|
||||
const isHighlight = isFocused && isSelected;
|
||||
|
||||
const imgRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
cbModal({
|
||||
// @ts-expect-error: Component is badly typed
|
||||
component: (compProps) =>
|
||||
ImageEditModal({
|
||||
element,
|
||||
rte,
|
||||
path: rte.getPath(element),
|
||||
...compProps,
|
||||
}),
|
||||
modalProps: {
|
||||
size: "max",
|
||||
},
|
||||
});
|
||||
}, [element, rte]);
|
||||
|
||||
const ToolTipButtons = () => {
|
||||
return (
|
||||
<div contentEditable={false} className="embed--btn-group">
|
||||
<EmbedBtn title="edit" content="Edit" onClick={handleEdit}>
|
||||
<Icon icon="Rename" />
|
||||
</EmbedBtn>
|
||||
|
||||
<EmbedBtn
|
||||
title="remove"
|
||||
content={"Remove"}
|
||||
onClick={() => rte?.removeNode(element)}
|
||||
>
|
||||
<Icon icon="Trash" />
|
||||
</EmbedBtn>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const onResizeStop = () => {
|
||||
const { attrs: elementAttrs } = element;
|
||||
const { offsetWidth: width, offsetHeight: height } = imgRef?.current ?? {};
|
||||
|
||||
const newAttrs: { [key: string]: unknown } = {
|
||||
...elementAttrs,
|
||||
style: {
|
||||
...(elementAttrs?.style ?? {}),
|
||||
"max-width": `${width}px`,
|
||||
},
|
||||
...(width && height
|
||||
? { width: `${width.toString()}px`, height: `${height.toString()}px` }
|
||||
: {}),
|
||||
};
|
||||
|
||||
rte?._adv?.Transforms?.setNodes<IRteElementType>(
|
||||
rte._adv.editor,
|
||||
{ attrs: newAttrs },
|
||||
{ at: rte.getPath(element) }
|
||||
);
|
||||
};
|
||||
|
||||
let alignmentStyles = {};
|
||||
const marginAlignment: Record<string, { [key: string]: string }> = {
|
||||
center: { margin: "auto" },
|
||||
left: { marginRight: "auto" },
|
||||
right: { marginLeft: "auto" },
|
||||
};
|
||||
|
||||
if (typeof element.attrs.position === "string") {
|
||||
alignmentStyles = marginAlignment[element.attrs.position];
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...alignmentStyles,
|
||||
...element.attrs.style,
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
zIndex={909}
|
||||
className="p-0"
|
||||
style={{ marginBottom: "10px" }}
|
||||
position="top"
|
||||
variantType="light"
|
||||
offset={[0, -15]}
|
||||
content={<ToolTipButtons />}
|
||||
>
|
||||
<span
|
||||
data-type="asset"
|
||||
contentEditable={false}
|
||||
style={element.attrs?.style}
|
||||
>
|
||||
<Resizable
|
||||
lockAspectRatio
|
||||
size={{
|
||||
width: element.attrs.width ?? "180px",
|
||||
height: element.attrs.heigth ?? "auto",
|
||||
}}
|
||||
onResizeStop={onResizeStop}
|
||||
handleStyles={{
|
||||
right: { right: 0, width: "15px" },
|
||||
left: { left: 0, width: "15px" },
|
||||
bottom: { bottom: "0" },
|
||||
bottomRight: { width: "15px" },
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={imgRef}
|
||||
contentEditable={false}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...(isHighlight ? { border: "1px solid #6c5ce7" } : {}),
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={assetUrl}
|
||||
onError={(event) => {
|
||||
event.currentTarget.src = "https://placehold.co/600x400";
|
||||
}}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: element.attrs.MediaConversions[0].AspectRatio,
|
||||
borderRadius: "8px",
|
||||
}}
|
||||
alt={element.attrs.altText}
|
||||
title={element.attrs?.Name}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
background: "#fff",
|
||||
color: "#000",
|
||||
height: "25px",
|
||||
padding: "3px",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
>
|
||||
<Icon icon="Embed" />
|
||||
</div>
|
||||
</div>
|
||||
</Resizable>
|
||||
|
||||
{children}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user