Skip to content
File

Blob: src/shared/editor/presentation/image.tsx

typescript129 lines
1import { forwardRef } from "react";
2import type { CSSProperties, ImgHTMLAttributes, ReactNode } from "react";
3import { ImageIcon } from "lucide-react";
4import { Skeleton } from "@/shared/components/ui/skeleton";
5import {
6 MAX_IMAGE_BRIGHTNESS,
7 normalizeImageAlign,
8 normalizeImageBrightness,
9} from "@/shared/editor/schema/image-model";
10import { bidAttribute, type BlockIdentityProps } from "./attrs";
11 
12export const IMAGE_FRAME_FALLBACK_ASPECT_RATIO = 16 / 9;
13 
14export interface ImagePresentationProps extends BlockIdentityProps {
15 src?: string | null;
16 alt?: string | null;
17 title?: string | null;
18 align?: unknown;
19 width?: number | null;
20 brightness?: unknown;
21}
22 
23interface ImageElementProps extends ImgHTMLAttributes<HTMLImageElement> {
24 brightness?: unknown;
25}
26 
27export const ImageElement = forwardRef<HTMLImageElement, ImageElementProps>(function ImageElement(
28 { brightness, style, ...props },
29 ref,
30) {
31 const value = normalizeImageBrightness(brightness);
32 const imageStyle = value === MAX_IMAGE_BRIGHTNESS ? style : { ...style, filter: `brightness(${value / 100})` };
33 return (
34 <img
35 {...props}
36 className={["tiptap-image", props.className].filter(Boolean).join(" ")}
37 style={imageStyle}
38 ref={ref}
39 />
40 );
41});
42 
43export function resolveImageAlignClass(align: unknown): string {
44 const normalized = normalizeImageAlign(align);
45 return normalized === "center"
46 ? "tiptap-image-node--align-center"
47 : normalized === "right"
48 ? "tiptap-image-node--align-right"
49 : "tiptap-image-node--align-left";
50}
51 
52export interface ImageFrameStyleInput {
53 width?: number | null;
54 naturalWidth?: number | null;
55 naturalHeight?: number | null;
56}
57 
58export function resolveImageFrameStyle({ width, naturalWidth, naturalHeight }: ImageFrameStyleInput): CSSProperties {
59 const aspectRatio =
60 naturalWidth && naturalHeight && naturalWidth > 0 && naturalHeight > 0
61 ? naturalWidth / naturalHeight
62 : IMAGE_FRAME_FALLBACK_ASPECT_RATIO;
63 const frameWidth = width && width > 0 ? width : naturalWidth && naturalWidth > 0 ? naturalWidth : null;
64 return {
65 aspectRatio: String(aspectRatio),
66 width: frameWidth !== null ? `${frameWidth}px` : "100%",
67 };
68}
69 
70export interface ImageFigureFrameProps extends BlockIdentityProps {
71 align?: unknown;
72 containerClassName?: string;
73 containerStyle?: CSSProperties;
74 caption?: ReactNode;
75 children: ReactNode;
76}
77 
78export function ImageFigureFrame({
79 bid,
80 align,
81 containerClassName,
82 containerStyle,
83 caption,
84 children,
85}: ImageFigureFrameProps) {
86 const alignClass = resolveImageAlignClass(align);
87 const containerClass = ["tiptap-image-container", containerClassName].filter(Boolean).join(" ");
88 return (
89 <figure className={`tiptap-image-node ${alignClass}`} {...bidAttribute(bid)}>
90 <span className={containerClass} style={containerStyle}>
91 {children}
92 {caption ? <figcaption className="tiptap-image-alt">{caption}</figcaption> : null}
93 </span>
94 </figure>
95 );
96}
97 
98export function ImageLoadingSkeleton() {
99 return <Skeleton className="tiptap-image-load-skeleton" aria-hidden="true" />;
100}
101 
102export function ImageErrorState() {
103 return (
104 <div className="tiptap-image-error">
105 <ImageIcon size={20} />
106 <span>Image failed to load</span>
107 </div>
108 );
109}
110 
111export function ImagePresentation({ bid, src, alt, title, align, width, brightness }: ImagePresentationProps) {
112 if (!src) return null;
113 
114 const containerStyle: CSSProperties | undefined = width && width > 0 ? { width: `${width}px` } : undefined;
115 const imageStyle: CSSProperties | undefined = width && width > 0 ? { width: "100%" } : undefined;
116 
117 return (
118 <ImageFigureFrame bid={bid} align={align} containerStyle={containerStyle} caption={alt}>
119 <ImageElement
120 src={src}
121 alt={alt ?? undefined}
122 title={title ?? undefined}
123 style={imageStyle}
124 brightness={brightness}
125 />
126 </ImageFigureFrame>
127 );
128}