feat: Contentstack <-> ImageVault integration

This commit is contained in:
Michael Zetterberg
2024-03-25 11:38:14 +01:00
parent 920cbf241a
commit a706b9cf8a
39 changed files with 16647 additions and 0 deletions

90
rte/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,90 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
ignorePatterns: ['node_modules/**/*'],
// Base config
extends: ['eslint:recommended'],
overrides: [
// React
{
files: ['**/*.{js,jsx,ts,tsx}'],
plugins: ['react', 'jsx-a11y'],
rules: {
'react/jsx-uses-vars': 'error',
'react/jsx-uses-react': 'error',
},
extends: [
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
'plugin:jsx-a11y/recommended',
],
settings: {
react: {
version: 'detect',
},
formComponents: ['Form'],
linkComponents: [
{ name: 'Link', linkAttribute: 'to' },
{ name: 'NavLink', linkAttribute: 'to' },
],
'import/resolver': {
typescript: {
project: './rte/tsconfig.json',
},
},
},
},
// Typescript
{
files: ['**/*.{ts,tsx}'],
plugins: ['@typescript-eslint', 'import'],
parser: '@typescript-eslint/parser',
settings: {
'import/internal-regex': '^~/',
'import/resolver': {
node: {
extensions: ['.ts', '.tsx'],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
],
},
// Node
{
files: ['.eslintrc.cjs'],
env: {
node: true,
},
},
],
};

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

1
rte/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const IS_DEV: boolean;

224
rte/main.tsx Normal file
View File

@@ -0,0 +1,224 @@
import React from 'react';
import ContentstackSDK from '@contentstack/app-sdk';
import { ImageElement } from '~/components/ImageElement';
import { Icon } from '@contentstack/venus-components';
import { openImageVault } from '~/utils/imagevault';
import type { Lang } from '~/types/lang';
import type {
ContentstackEmbeddedData,
ContentstackPluginDefinition,
ExtractedContentstackEmbeddedData,
} from '~/types/contentstack';
function findThisPlugin(ext: ContentstackPluginDefinition) {
return ext.type === 'rte_plugin' && ext.title === 'ImageVault';
}
async function loadScript(url: string) {
return new Promise((resolve, reject) => {
if (!document) {
throw new Error('Run in browser only');
}
const head = document.head || document.getElementsByTagName('head')[0];
const script = document.createElement('script');
script.type = 'text/javascript';
script.async = true;
script.src = url;
script.onload = function () {
this.onerror = this.onload = null;
resolve(window.ImageVault);
};
script.onerror = function () {
this.onerror = this.onload = null;
reject(`Failed to load ${this.src}`);
};
head.appendChild(script);
});
}
function extractContentstackEmbeddedData(
jwtLike: string
): ExtractedContentstackEmbeddedData | null {
try {
const base64str = jwtLike.replace(/-/g, "+").replace(/_/g, "/");
const jsonStr = decodeURIComponent(
window
.atob(base64str)
.split("")
.map(function (c) {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
})
.join("")
);
const json = JSON.parse(jsonStr);
json.exports = JSON.parse(json.exports);
json.props.value = JSON.parse(json.props.value);
if (IS_DEV) {
console.log(`Contentstack Embedded Data`, json);
}
const {
entryMetadata,
utilis: {
content_type: { schema },
extensions,
},
requestProps: {
stack: { api_key },
branch,
},
}: ContentstackEmbeddedData = json.props.value;
const titleField = schema.find((f) => f.uid === "title");
// We force this value with ! because we know it exists.
// Otherwise this code would not run.
const plugin = extensions.find(findThisPlugin)!;
return {
stack: {
apiKey: api_key,
},
branch,
contentType: {
uid: entryMetadata.contentTypeUid,
},
entry: {
locale: entryMetadata.locale,
title:
titleField && titleField.data_type === "text"
? titleField.value
: "Untitled",
uid: entryMetadata.entryUid,
},
plugin,
};
} catch (e) {
console.log(`Unable to parse JWT like: ${jwtLike}`);
}
return null;
}
let ivloaded = false;
function loadIV(plugin: ContentstackPluginDefinition) {
if (plugin.src) {
const url = new URL(plugin.src);
url.pathname = "/scripts/imagevault-insert-media/insertmediawindow.min.js";
if (IS_DEV) {
console.log(`Loading script: ${url.toString()}`);
}
loadScript(url.toString());
ivloaded = true;
}
}
async function init() {
try {
const sdk = await ContentstackSDK.init();
const extensionObj = sdk?.location;
const RTE = extensionObj?.RTEPlugin;
let embeddedData: ExtractedContentstackEmbeddedData | null = null;
const jwtLike = window.name.split("__").find((str) => str.startsWith("ey"));
if (jwtLike) {
embeddedData = extractContentstackEmbeddedData(jwtLike);
if (embeddedData?.plugin) {
loadIV(embeddedData.plugin);
}
}
if (!RTE) return;
const ImageVault = RTE("ImageVault", (rte) => {
if (rte) {
if (!ivloaded) {
// Loading failed via embedded data above, try again with data inside RTE
// @ts-expect-error: incorrect typings, requestProps is available at runtime
const extensions = rte._adv.editor.requestProps
.extensions as ContentstackPluginDefinition[];
const plugin = extensions.find(findThisPlugin);
if (plugin) {
loadIV(plugin);
}
}
}
return {
title: "Choose assets from ImageVault",
icon: <Icon icon="Image" version="v2" height={24} width={24} />,
render: (props) => {
if (rte) {
return (
<ImageElement element={props.element} rte={rte}>
{props.children}
</ImageElement>
);
} else {
console.error("No instance of RTE found");
return (
<p>An unexpected error occured, see console for more info.</p>
);
}
},
display: ["toolbar"],
elementType: ["void"],
};
});
ImageVault.on("exec", async (rte) => {
if (rte) {
const savedSelection = rte?.selection?.get() ?? undefined;
// @ts-expect-error: Added at runtime
const config = await rte.getConfig();
openImageVault({
config,
entryData: {
//TODO: Add support for branches
branch: embeddedData ? embeddedData.contentType.uid : "main",
contentTypeUid: embeddedData
? embeddedData.contentType.uid
: "Unknown",
locale: embeddedData
? embeddedData.entry.locale
: ("unknown" as Lang),
stackApiKey: embeddedData ? embeddedData.stack.apiKey : "unknown",
title: embeddedData ? embeddedData.entry.title : "Untitled",
uid: embeddedData ? embeddedData.entry.uid : "unknown",
},
onSuccess(result) {
rte.insertNode(
{
// @ts-expect-error: incorrect typings
type: "ImageVault",
attrs: result,
uid: crypto.randomUUID(),
children: [{ text: "" }],
},
{ at: savedSelection }
);
},
});
}
});
return {
ImageVault,
};
} catch (e) {
console.error({ e });
}
}
export default init();

19
rte/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "@scandichotels/contentstack-imagevault-rte",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "IS_DEV=true vite build --watch",
"lint": "eslint --ignore-path ../.gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"prebuild": "concurrently npm:lint npm:typecheck",
"typecheck": "tsc"
},
"devDependencies": {
"@types/systemjs": "^6.13.5"
},
"engines": {
"node": ">=18.0.0"
}
}

19
rte/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "../tsconfig.json",
"include": [
"env.d.ts",
"../types/**/*.ts",
"../utils/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"compilerOptions": {
"jsx": "react",
"baseUrl": ".",
"paths": {
"~/*": ["./*"],
"~/types/*": ["../types/*"],
"~/utils/*": ["../utils/*"]
}
}
}

28
rte/vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { resolve } from 'path';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()],
define: {
IS_DEV: process.env.IS_DEV === 'true' ? true : false,
},
publicDir: false,
build: {
sourcemap: process.env.IS_DEV ? 'inline' : 'hidden',
emptyOutDir: true,
lib: {
entry: resolve(__dirname, 'main.tsx'),
name: 'csiv',
fileName: () => (process.env.IS_DEV ? 'csiv.js' : 'csiv-[hash].js'),
// @ts-expect-error: 'system' not valid by typings, but works with Rollup
formats: ['system'],
},
rollupOptions: {
external: ['react', 'react-dom', '@contentstack/venus-components'],
output: {
dir: '../remix/public/build/rte',
},
},
},
});