Skip to content
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
2 changes: 1 addition & 1 deletion .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
working-directory: ./typescript
run: npm ci

- name: Extract version from tag and update package.json
- name: Extract version from tag and update npm version
working-directory: ./typescript
run: |
# Get the version from the tag (remove 'typescript-v' prefix)
Expand Down
4 changes: 3 additions & 1 deletion typescript/bump.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { defineConfig } from "bumpp";

export default defineConfig({
// ...options
commit: false,
tag: "typescript-v%s",
push: false,
});
15 changes: 15 additions & 0 deletions typescript/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"json5": "^2.2.3",
"katex": "^0.16.22",
"puppeteer": "^24.9.0",
"react-intersection-observer": "^9.16.0",
"strip-json-comments": "^5.0.2"
},
"peerDependencies": {
Expand Down
3 changes: 3 additions & 0 deletions typescript/src/renderer/JsonDocRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ interface JsonDocRendererProps {
className?: string;
components?: React.ComponentProps<typeof BlockRenderer>["components"];
theme?: "light" | "dark";
resolveImageUrl?: (url: string) => Promise<string>;
}

export const JsonDocRenderer = ({
page,
className = "",
components,
theme = "light",
resolveImageUrl,
}: JsonDocRendererProps) => {
return (
<div className={`json-doc-renderer jsondoc-theme-${theme} ${className}`}>
Expand Down Expand Up @@ -42,6 +44,7 @@ export const JsonDocRenderer = ({
block={block}
depth={0}
components={components}
resolveImageUrl={resolveImageUrl}
/>
))}
</div>
Expand Down
6 changes: 5 additions & 1 deletion typescript/src/renderer/components/BlockRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@ interface BlockRendererProps {
block: any;
depth?: number;
components?: BlockComponents;
resolveImageUrl?: (url: string) => Promise<string>;
}

export const BlockRenderer: React.FC<BlockRendererProps> = ({
block,
depth = 0,
components,
resolveImageUrl,
}) => {
const commonProps = { block, depth, components };

Expand Down Expand Up @@ -106,7 +108,9 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
// Image block
if (block?.type === "image") {
const ImageComponent = components?.image || ImageBlockRenderer;
return <ImageComponent {...commonProps} />;
return (
<ImageComponent {...commonProps} resolveImageUrl={resolveImageUrl} />
);
}

// Table blocks
Expand Down
105 changes: 92 additions & 13 deletions typescript/src/renderer/components/blocks/ImageBlockRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,46 @@
import React from "react";
import React, { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";

import { RichTextRenderer } from "../RichTextRenderer";
import { BlockRenderer } from "../BlockRenderer";

const ImagePlaceholderIcon: React.FC = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#9ca3af"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
);

interface ImageBlockRendererProps extends React.HTMLAttributes<HTMLDivElement> {
block: any;
depth?: number;
components?: React.ComponentProps<typeof BlockRenderer>["components"];
resolveImageUrl?: (url: string) => Promise<string>;
}

export const ImageBlockRenderer: React.FC<ImageBlockRendererProps> = ({
block,
depth = 0,
className,
components,
resolveImageUrl,
...props
}) => {
const imageData = block.image;
const [url, setUrl] = useState<string>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [hasError, setHasError] = useState<boolean>(false);
const { ref, inView } = useInView({ threshold: 0.1, triggerOnce: true });

const getImageUrl = () => {
if (imageData?.type === "external") {
Expand All @@ -29,6 +53,38 @@ export const ImageBlockRenderer: React.FC<ImageBlockRendererProps> = ({

const imageUrl = getImageUrl();

useEffect(() => {
let cancelled = false;

const imageUrlEffect = async () => {
if (resolveImageUrl && imageUrl) {
setIsLoading(true);
setHasError(false);
try {
const url_ = await resolveImageUrl(imageUrl);
if (!cancelled) {
setUrl(url_);
setIsLoading(false);
}
} catch (error) {
if (!cancelled) {
setHasError(true);
setIsLoading(false);
console.error("Failed to resolve image URL:", error);
}
}
}
};

if (inView && imageUrl) {
imageUrlEffect();
}

return () => {
cancelled = true;
};
}, [inView, imageUrl, resolveImageUrl]);

return (
<div
{...props}
Expand All @@ -37,24 +93,47 @@ export const ImageBlockRenderer: React.FC<ImageBlockRendererProps> = ({
>
<div className="notion-selectable-container">
<div role="figure">
<div className="notion-cursor-default">
<div>
{imageUrl && (
<img
alt=""
src={imageUrl}
style={{ maxWidth: "100%", height: "auto" }}
/>
)}
</div>
<div className="notion-cursor-default" ref={ref}>
{imageUrl && (
<div
style={{
position: "relative",
width: "100%",
maxWidth: "600px",
}}
>
{(isLoading || (!url && resolveImageUrl)) && !hasError && (
<div className="image-loading-placeholder">
<div className="image-loading-content">
<div className="image-loading-icon">
<ImagePlaceholderIcon />
</div>
<div className="image-loading-text">Loading image...</div>
</div>
</div>
)}
{hasError && (
<div className="image-error-placeholder">
<div className="image-error-text">Failed to load image</div>
</div>
)}
{!isLoading && !hasError && (url || !resolveImageUrl) && (
<img
alt={imageData?.caption ? "" : "Image"}
src={url || imageUrl}
onError={() => setHasError(true)}
/>
)}
</div>
)}
</div>
{/* Caption */}
{imageData?.caption && imageData.caption.length > 0 && (
<div>
<figcaption className="notion-image-caption">
<div className="notranslate">
<RichTextRenderer richText={imageData.caption} />
</div>
</div>
</figcaption>
)}
</div>
</div>
Expand Down
89 changes: 88 additions & 1 deletion typescript/src/renderer/styles/media.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,88 @@

.notion-image-block {
padding: var(--jsondoc-spacing-sm) var(--jsondoc-spacing-xs);
margin: var(--jsondoc-spacing-md) 0;
}

.notion-image-block figure,
.notion-image-block [role="figure"] {
margin: 0;
padding: 0;
}

.notion-image-block img {
max-width: 100%;
height: auto;
display: block;
border-radius: var(--jsondoc-radius-sm);
box-shadow:
rgba(15, 15, 15, 0.1) 0px 0px 0px 1px,
rgba(15, 15, 15, 0.1) 0px 2px 4px;
}

/* Image Block Loading States */
.image-loading-placeholder {
width: 100%;
height: 300px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 12px;
position: relative;
overflow: hidden;
}

.image-loading-content {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}

.image-loading-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(107, 114, 128, 0.1);
display: flex;
align-items: center;
justify-content: center;
}

.image-loading-text {
color: #9ca3af;
font-size: 14px;
font-weight: 500;
}

@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}

/* Image Block Error State */
.image-error-placeholder {
width: 100%;
height: 300px;
background-color: #fef2f2;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
border: 1px solid #fecaca;
}

.image-error-text {
color: #dc2626;
font-size: 14px;
}

.notion-image-placeholder {
Expand Down Expand Up @@ -38,7 +120,12 @@
}

.notion-image-caption {
color: var(--jsondoc-text-primary);
color: var(--jsondoc-text-muted);
font-size: var(--jsondoc-font-size-caption);
line-height: var(--jsondoc-line-height-relaxed);
text-align: center;
margin-top: var(--jsondoc-spacing-md);
margin-bottom: var(--jsondoc-spacing-sm);
font-style: italic;
max-width: 100%;
}
2 changes: 1 addition & 1 deletion typescript/src/renderer/styles/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
--jsondoc-font-size-h2: clamp(1.25rem, 3vw, 1.5rem);
--jsondoc-font-size-h3: clamp(1.125rem, 2.5vw, 1.25rem);
--jsondoc-font-size-body: 1rem;
--jsondoc-font-size-caption: 14px;
--jsondoc-font-size-caption: 12px;
--jsondoc-font-size-code: 14px;
--jsondoc-font-size-code-language: 12px;
--jsondoc-font-size-inline-code: 85%;
Expand Down
Loading