electron
This commit is contained in:
@@ -8,13 +8,31 @@ type Modal500Props = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
|
const handleClickOutside = (e: MouseEvent | TouchEvent | FocusEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Don't close if clicking on a popout menu, overlay, or other portaled content
|
||||||
|
// Check for common portal container elements and popout menus
|
||||||
|
if (
|
||||||
|
target.closest('[role="menu"]') ||
|
||||||
|
target.closest('[role="dialog"]') ||
|
||||||
|
target.closest('[data-floating-ui-portal]') ||
|
||||||
|
target.closest('.folds-overlay') ||
|
||||||
|
target.closest('.popout')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: handleClickOutside,
|
||||||
onDeactivate: requestClose,
|
onDeactivate: requestClose,
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AvatarFallback, AvatarImage, color } from 'folds';
|
import { AvatarFallback, AvatarImage, color } from 'folds';
|
||||||
import React, { ReactEventHandler, ReactNode, useState } from 'react';
|
import React, { ReactEventHandler, ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import * as css from './UserAvatar.css';
|
import * as css from './UserAvatar.css';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
@@ -7,6 +7,13 @@ import { useAuthenticatedMediaUrl } from '../../hooks/useAuthenticatedMediaUrl';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
import { cacheAvatar, isAvatarCached, pruneAvatarCache } from '../../utils/avatarCache';
|
||||||
|
|
||||||
|
// Check if image is already in browser cache
|
||||||
|
function isImageInBrowserCache(url: string): boolean {
|
||||||
|
const img = new Image();
|
||||||
|
img.src = url;
|
||||||
|
return img.complete && img.naturalWidth > 0;
|
||||||
|
}
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -16,10 +23,29 @@ type UserAvatarProps = {
|
|||||||
};
|
};
|
||||||
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
|
export function UserAvatar({ className, userId, src, alt, renderFallback }: UserAvatarProps) {
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [loaded, setLoaded] = useState(() => isAvatarCached(src));
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
const authenticatedSrc = useAuthenticatedMediaUrl(src, useAuthentication);
|
||||||
|
|
||||||
|
// Check both our cache and browser's native cache
|
||||||
|
const [loaded, setLoaded] = useState(() => {
|
||||||
|
if (isAvatarCached(src)) return true;
|
||||||
|
if (authenticatedSrc && isImageInBrowserCache(authenticatedSrc)) {
|
||||||
|
cacheAvatar(src);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
// Also check on mount if image is already complete (browser cached)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loaded && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
|
||||||
|
setLoaded(true);
|
||||||
|
cacheAvatar(src);
|
||||||
|
}
|
||||||
|
}, [authenticatedSrc, loaded, src]);
|
||||||
|
|
||||||
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
const handleLoad: ReactEventHandler<HTMLImageElement> = (evt) => {
|
||||||
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
evt.currentTarget.setAttribute('data-image-loaded', 'true');
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
@@ -54,7 +80,8 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading state - render fallback with image overlay
|
// Loading state - render image with hidden fallback behind it
|
||||||
|
// Image loads invisibly, then we show it once complete
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
|
||||||
<AvatarFallback
|
<AvatarFallback
|
||||||
@@ -69,6 +96,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
{renderFallback()}
|
{renderFallback()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
|
ref={imgRef}
|
||||||
className={classNames(css.UserAvatar, className)}
|
className={classNames(css.UserAvatar, className)}
|
||||||
style={{ position: 'relative', zIndex: 1 }}
|
style={{ position: 'relative', zIndex: 1 }}
|
||||||
src={authenticatedSrc}
|
src={authenticatedSrc}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
|
|||||||
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
||||||
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
|
import { TitleBar } from '../../components/title-bar';
|
||||||
|
|
||||||
const currentAuthPath = (pathname: string): string => {
|
const currentAuthPath = (pathname: string): string => {
|
||||||
if (matchPath(LOGIN_PATH, pathname)) {
|
if (matchPath(LOGIN_PATH, pathname)) {
|
||||||
@@ -123,87 +124,90 @@ export function AuthLayout() {
|
|||||||
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
|
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
<>
|
||||||
<Box
|
<TitleBar />
|
||||||
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
|
||||||
direction="Column"
|
<Box
|
||||||
alignItems="Center"
|
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
|
||||||
justifyContent="SpaceBetween"
|
direction="Column"
|
||||||
gap="400"
|
alignItems="Center"
|
||||||
>
|
justifyContent="SpaceBetween"
|
||||||
<Box direction="Column" className={css.AuthCard}>
|
gap="400"
|
||||||
<Header className={css.AuthHeader} size="600" variant="Surface">
|
>
|
||||||
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
<Box direction="Column" className={css.AuthCard}>
|
||||||
<img className={css.AuthLogo} src={PaarrotSVG} alt="Paarrot Logo" />
|
<Header className={css.AuthHeader} size="600" variant="Surface">
|
||||||
<Text size="H3">Paarrot</Text>
|
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
|
||||||
|
<img className={css.AuthLogo} src={PaarrotSVG} alt="Paarrot Logo" />
|
||||||
|
<Text size="H3">Paarrot</Text>
|
||||||
|
</Box>
|
||||||
|
</Header>
|
||||||
|
<Box className={css.AuthCardContent} direction="Column">
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text as="label" size="L400" priority="300">
|
||||||
|
Homeserver
|
||||||
|
</Text>
|
||||||
|
<ServerPicker
|
||||||
|
server={server}
|
||||||
|
serverList={clientConfig.homeserverList ?? []}
|
||||||
|
allowCustomServer={clientConfig.allowCustomHomeservers}
|
||||||
|
onServerChange={selectServer}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{discoveryState.status === AsyncStatus.Loading && (
|
||||||
|
<AuthLayoutLoading message="Looking for homeserver..." />
|
||||||
|
)}
|
||||||
|
{discoveryState.status === AsyncStatus.Error && (
|
||||||
|
<AuthLayoutError message="Failed to find homeserver." />
|
||||||
|
)}
|
||||||
|
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
||||||
|
<AuthLayoutError
|
||||||
|
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
||||||
|
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
|
||||||
|
)}
|
||||||
|
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
|
||||||
|
<AuthServerProvider value={discoveryState.data.serverName}>
|
||||||
|
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
|
||||||
|
<SpecVersionsLoader
|
||||||
|
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
||||||
|
fallback={() => (
|
||||||
|
<AuthLayoutLoading
|
||||||
|
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
error={() => (
|
||||||
|
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(specVersions) => (
|
||||||
|
<SpecVersionsProvider value={specVersions}>
|
||||||
|
<AuthFlowsLoader
|
||||||
|
fallback={() => (
|
||||||
|
<AuthLayoutLoading message="Loading authentication flow..." />
|
||||||
|
)}
|
||||||
|
error={() => (
|
||||||
|
<AuthLayoutError message="Failed to get authentication flow information." />
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(authFlows) => (
|
||||||
|
<AuthFlowsProvider value={authFlows}>
|
||||||
|
<Outlet />
|
||||||
|
</AuthFlowsProvider>
|
||||||
|
)}
|
||||||
|
</AuthFlowsLoader>
|
||||||
|
</SpecVersionsProvider>
|
||||||
|
)}
|
||||||
|
</SpecVersionsLoader>
|
||||||
|
</AutoDiscoveryInfoProvider>
|
||||||
|
</AuthServerProvider>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
|
||||||
<Box className={css.AuthCardContent} direction="Column">
|
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text as="label" size="L400" priority="300">
|
|
||||||
Homeserver
|
|
||||||
</Text>
|
|
||||||
<ServerPicker
|
|
||||||
server={server}
|
|
||||||
serverList={clientConfig.homeserverList ?? []}
|
|
||||||
allowCustomServer={clientConfig.allowCustomHomeservers}
|
|
||||||
onServerChange={selectServer}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
{discoveryState.status === AsyncStatus.Loading && (
|
|
||||||
<AuthLayoutLoading message="Looking for homeserver..." />
|
|
||||||
)}
|
|
||||||
{discoveryState.status === AsyncStatus.Error && (
|
|
||||||
<AuthLayoutError message="Failed to find homeserver." />
|
|
||||||
)}
|
|
||||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
|
|
||||||
<AuthLayoutError
|
|
||||||
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
|
|
||||||
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
|
|
||||||
)}
|
|
||||||
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
|
|
||||||
<AuthServerProvider value={discoveryState.data.serverName}>
|
|
||||||
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
|
|
||||||
<SpecVersionsLoader
|
|
||||||
baseUrl={autoDiscoveryInfo['m.homeserver'].base_url}
|
|
||||||
fallback={() => (
|
|
||||||
<AuthLayoutLoading
|
|
||||||
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
error={() => (
|
|
||||||
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(specVersions) => (
|
|
||||||
<SpecVersionsProvider value={specVersions}>
|
|
||||||
<AuthFlowsLoader
|
|
||||||
fallback={() => (
|
|
||||||
<AuthLayoutLoading message="Loading authentication flow..." />
|
|
||||||
)}
|
|
||||||
error={() => (
|
|
||||||
<AuthLayoutError message="Failed to get authentication flow information." />
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{(authFlows) => (
|
|
||||||
<AuthFlowsProvider value={authFlows}>
|
|
||||||
<Outlet />
|
|
||||||
</AuthFlowsProvider>
|
|
||||||
)}
|
|
||||||
</AuthFlowsLoader>
|
|
||||||
</SpecVersionsProvider>
|
|
||||||
)}
|
|
||||||
</SpecVersionsLoader>
|
|
||||||
</AutoDiscoveryInfoProvider>
|
|
||||||
</AuthServerProvider>
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<AuthFooter />
|
||||||
</Box>
|
</Box>
|
||||||
<AuthFooter />
|
</Scroll>
|
||||||
</Box>
|
</>
|
||||||
</Scroll>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,10 +185,7 @@ function createPaarrotExtension(color: string): Uint8Array {
|
|||||||
export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[extractColorFromGIF] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
if (!isGIF(data)) {
|
if (!isGIF(data)) {
|
||||||
console.error('[extractColorFromGIF] Not a GIF file');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,19 +193,16 @@ export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string
|
|||||||
const startOffset = 13 + gctSize; // Header(6) + LSD(7) + GCT
|
const startOffset = 13 + gctSize; // Header(6) + LSD(7) + GCT
|
||||||
|
|
||||||
const extensions = findAppExtensions(data, startOffset);
|
const extensions = findAppExtensions(data, startOffset);
|
||||||
console.log('[extractColorFromGIF] Found', extensions.length, 'application extensions');
|
|
||||||
|
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
if (ext.appId.startsWith('PAARROT')) {
|
if (ext.appId.startsWith('PAARROT')) {
|
||||||
if (ext.dataBlocks.length > 0) {
|
if (ext.dataBlocks.length > 0) {
|
||||||
const color = new TextDecoder().decode(ext.dataBlocks[0]);
|
const color = new TextDecoder().decode(ext.dataBlocks[0]);
|
||||||
console.log('[extractColorFromGIF] Found color:', color);
|
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[extractColorFromGIF] No color found');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,10 +212,7 @@ export function extractColorFromGIF(imageData: ArrayBuffer | Uint8Array): string
|
|||||||
export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[embedColorInGIF] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
if (!isGIF(data)) {
|
if (!isGIF(data)) {
|
||||||
console.error('[embedColorInGIF] Not a GIF file');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +220,6 @@ export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: stri
|
|||||||
const insertOffset = 13 + gctSize; // After Header + LSD + GCT
|
const insertOffset = 13 + gctSize; // After Header + LSD + GCT
|
||||||
|
|
||||||
const extensions = findAppExtensions(data, insertOffset);
|
const extensions = findAppExtensions(data, insertOffset);
|
||||||
console.log('[embedColorInGIF] Found', extensions.length, 'application extensions');
|
|
||||||
|
|
||||||
// Find existing PAARROT extension
|
// Find existing PAARROT extension
|
||||||
let existingExt: { offset: number; length: number } | null = null;
|
let existingExt: { offset: number; length: number } | null = null;
|
||||||
@@ -243,14 +233,10 @@ export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: stri
|
|||||||
// Create new extension
|
// Create new extension
|
||||||
const newExtension = createPaarrotExtension(color);
|
const newExtension = createPaarrotExtension(color);
|
||||||
|
|
||||||
console.log('[embedColorInGIF] Created extension, size:', newExtension.length, 'bytes');
|
|
||||||
console.log('[embedColorInGIF] Color being embedded:', color);
|
|
||||||
|
|
||||||
let newData: Uint8Array;
|
let newData: Uint8Array;
|
||||||
|
|
||||||
if (existingExt) {
|
if (existingExt) {
|
||||||
// Replace existing extension
|
// Replace existing extension
|
||||||
console.log('[embedColorInGIF] Replacing existing extension at offset', existingExt.offset);
|
|
||||||
newData = new Uint8Array(data.length - existingExt.length + newExtension.length);
|
newData = new Uint8Array(data.length - existingExt.length + newExtension.length);
|
||||||
newData.set(data.slice(0, existingExt.offset), 0);
|
newData.set(data.slice(0, existingExt.offset), 0);
|
||||||
newData.set(newExtension, existingExt.offset);
|
newData.set(newExtension, existingExt.offset);
|
||||||
@@ -260,15 +246,12 @@ export function embedColorInGIF(imageData: ArrayBuffer | Uint8Array, color: stri
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Insert after GCT (before any other blocks)
|
// Insert after GCT (before any other blocks)
|
||||||
console.log('[embedColorInGIF] Inserting new extension at offset', insertOffset);
|
|
||||||
newData = new Uint8Array(data.length + newExtension.length);
|
newData = new Uint8Array(data.length + newExtension.length);
|
||||||
newData.set(data.slice(0, insertOffset), 0);
|
newData.set(data.slice(0, insertOffset), 0);
|
||||||
newData.set(newExtension, insertOffset);
|
newData.set(newExtension, insertOffset);
|
||||||
newData.set(data.slice(insertOffset), insertOffset + newExtension.length);
|
newData.set(data.slice(insertOffset), insertOffset + newExtension.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[embedColorInGIF] Output size:', newData.length, 'bytes');
|
|
||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,15 +145,11 @@ function isPaarrotXMP(segmentData: Uint8Array): boolean {
|
|||||||
export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[extractColorFromJPEG] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
if (!isJPEG(data)) {
|
if (!isJPEG(data)) {
|
||||||
console.error('[extractColorFromJPEG] Not a JPEG file');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = parseSegments(data);
|
const segments = parseSegments(data);
|
||||||
console.log('[extractColorFromJPEG] Found', segments.length, 'segments');
|
|
||||||
|
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
// APP1 marker (0xE1) is used for XMP
|
// APP1 marker (0xE1) is used for XMP
|
||||||
@@ -172,7 +168,6 @@ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): strin
|
|||||||
const xmpString = new TextDecoder().decode(segment.data.slice(XMP_NAMESPACE_BYTES.length));
|
const xmpString = new TextDecoder().decode(segment.data.slice(XMP_NAMESPACE_BYTES.length));
|
||||||
const color = extractColorFromXMP(xmpString);
|
const color = extractColorFromXMP(xmpString);
|
||||||
if (color) {
|
if (color) {
|
||||||
console.log('[extractColorFromJPEG] Found color:', color);
|
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,7 +175,6 @@ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[extractColorFromJPEG] No color found');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,15 +184,11 @@ export function extractColorFromJPEG(imageData: ArrayBuffer | Uint8Array): strin
|
|||||||
export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[embedColorInJPEG] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
if (!isJPEG(data)) {
|
if (!isJPEG(data)) {
|
||||||
console.error('[embedColorInJPEG] Not a JPEG file');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = parseSegments(data);
|
const segments = parseSegments(data);
|
||||||
console.log('[embedColorInJPEG] Found', segments.length, 'segments');
|
|
||||||
|
|
||||||
// Find existing paarrot XMP segment
|
// Find existing paarrot XMP segment
|
||||||
let existingSegment: { offset: number; length: number } | null = null;
|
let existingSegment: { offset: number; length: number } | null = null;
|
||||||
@@ -225,14 +215,10 @@ export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: str
|
|||||||
app1Segment.set(writeUint16BE(segmentLength), 2);
|
app1Segment.set(writeUint16BE(segmentLength), 2);
|
||||||
app1Segment.set(segmentData, 4);
|
app1Segment.set(segmentData, 4);
|
||||||
|
|
||||||
console.log('[embedColorInJPEG] Created APP1 segment, size:', app1Segment.length, 'bytes');
|
|
||||||
console.log('[embedColorInJPEG] Color being embedded:', color);
|
|
||||||
|
|
||||||
let newData: Uint8Array;
|
let newData: Uint8Array;
|
||||||
|
|
||||||
if (existingSegment) {
|
if (existingSegment) {
|
||||||
// Replace existing segment
|
// Replace existing segment
|
||||||
console.log('[embedColorInJPEG] Replacing existing segment at offset', existingSegment.offset);
|
|
||||||
newData = new Uint8Array(data.length - existingSegment.length + app1Segment.length);
|
newData = new Uint8Array(data.length - existingSegment.length + app1Segment.length);
|
||||||
newData.set(data.slice(0, existingSegment.offset), 0);
|
newData.set(data.slice(0, existingSegment.offset), 0);
|
||||||
newData.set(app1Segment, existingSegment.offset);
|
newData.set(app1Segment, existingSegment.offset);
|
||||||
@@ -242,15 +228,12 @@ export function embedColorInJPEG(imageData: ArrayBuffer | Uint8Array, color: str
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Insert after SOI marker (at offset 2)
|
// Insert after SOI marker (at offset 2)
|
||||||
console.log('[embedColorInJPEG] Inserting new segment after SOI');
|
|
||||||
newData = new Uint8Array(data.length + app1Segment.length);
|
newData = new Uint8Array(data.length + app1Segment.length);
|
||||||
newData.set(JPEG_SOI, 0);
|
newData.set(JPEG_SOI, 0);
|
||||||
newData.set(app1Segment, 2);
|
newData.set(app1Segment, 2);
|
||||||
newData.set(data.slice(2), 2 + app1Segment.length);
|
newData.set(data.slice(2), 2 + app1Segment.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[embedColorInJPEG] Output size:', newData.length, 'bytes');
|
|
||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,31 +141,24 @@ function parseTextChunk(data: Uint8Array): { key: string; value: string } | null
|
|||||||
export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[extractColorFromPNG] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
// Verify PNG signature
|
// Verify PNG signature
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
if (data[i] !== PNG_SIGNATURE[i]) {
|
if (data[i] !== PNG_SIGNATURE[i]) {
|
||||||
console.error('[extractColorFromPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
|
|
||||||
return undefined; // Not a PNG
|
return undefined; // Not a PNG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
console.log('[extractColorFromPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.type === 'tEXt') {
|
if (chunk.type === 'tEXt') {
|
||||||
const text = parseTextChunk(chunk.data);
|
const text = parseTextChunk(chunk.data);
|
||||||
console.log('[extractColorFromPNG] tEXt chunk found, key:', text?.key);
|
|
||||||
if (text && text.key === PAARROT_COLOR_KEY) {
|
if (text && text.key === PAARROT_COLOR_KEY) {
|
||||||
console.log('[extractColorFromPNG] Found color:', text.value);
|
|
||||||
return text.value;
|
return text.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[extractColorFromPNG] No color found');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,22 +171,14 @@ export function extractColorFromPNG(imageData: ArrayBuffer | Uint8Array): string
|
|||||||
export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[embedColorInPNG] Input size:', data.length, 'bytes');
|
|
||||||
console.log('[embedColorInPNG] First 8 bytes:', Array.from(data.slice(0, 8)));
|
|
||||||
console.log('[embedColorInPNG] PNG signature expected:', Array.from(PNG_SIGNATURE));
|
|
||||||
|
|
||||||
// Verify PNG signature
|
// Verify PNG signature
|
||||||
for (let i = 0; i < 8; i++) {
|
for (let i = 0; i < 8; i++) {
|
||||||
if (data[i] !== PNG_SIGNATURE[i]) {
|
if (data[i] !== PNG_SIGNATURE[i]) {
|
||||||
console.error('[embedColorInPNG] Not a PNG! Byte', i, 'is', data[i], 'expected', PNG_SIGNATURE[i]);
|
|
||||||
return null; // Not a PNG
|
return null; // Not a PNG
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[embedColorInPNG] PNG signature verified OK');
|
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
console.log('[embedColorInPNG] Found', chunks.length, 'chunks:', chunks.map(c => c.type).join(', '));
|
|
||||||
|
|
||||||
// Find IHDR chunk (always first) and any existing paarrot tEXt chunk
|
// Find IHDR chunk (always first) and any existing paarrot tEXt chunk
|
||||||
let insertOffset = 8; // After signature
|
let insertOffset = 8; // After signature
|
||||||
@@ -212,15 +197,12 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
|
|||||||
|
|
||||||
// Create new tEXt chunk with color
|
// Create new tEXt chunk with color
|
||||||
const colorChunk = createTextChunk(PAARROT_COLOR_KEY, color);
|
const colorChunk = createTextChunk(PAARROT_COLOR_KEY, color);
|
||||||
console.log('[embedColorInPNG] Created color chunk, size:', colorChunk.length, 'bytes');
|
|
||||||
console.log('[embedColorInPNG] Color being embedded:', color);
|
|
||||||
|
|
||||||
// Build new PNG data
|
// Build new PNG data
|
||||||
let newData: Uint8Array;
|
let newData: Uint8Array;
|
||||||
|
|
||||||
if (existingPaarrotChunk) {
|
if (existingPaarrotChunk) {
|
||||||
// Replace existing chunk
|
// Replace existing chunk
|
||||||
console.log('[embedColorInPNG] Replacing existing chunk at offset', existingPaarrotChunk.offset);
|
|
||||||
newData = new Uint8Array(data.length - existingPaarrotChunk.length + colorChunk.length);
|
newData = new Uint8Array(data.length - existingPaarrotChunk.length + colorChunk.length);
|
||||||
newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
|
newData.set(data.slice(0, existingPaarrotChunk.offset), 0);
|
||||||
newData.set(colorChunk, existingPaarrotChunk.offset);
|
newData.set(colorChunk, existingPaarrotChunk.offset);
|
||||||
@@ -230,15 +212,12 @@ export function embedColorInPNG(imageData: ArrayBuffer | Uint8Array, color: stri
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Insert new chunk after IHDR
|
// Insert new chunk after IHDR
|
||||||
console.log('[embedColorInPNG] Inserting new chunk at offset', insertOffset);
|
|
||||||
newData = new Uint8Array(data.length + colorChunk.length);
|
newData = new Uint8Array(data.length + colorChunk.length);
|
||||||
newData.set(data.slice(0, insertOffset), 0);
|
newData.set(data.slice(0, insertOffset), 0);
|
||||||
newData.set(colorChunk, insertOffset);
|
newData.set(colorChunk, insertOffset);
|
||||||
newData.set(data.slice(insertOffset), insertOffset + colorChunk.length);
|
newData.set(data.slice(insertOffset), insertOffset + colorChunk.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[embedColorInPNG] Output size:', newData.length, 'bytes');
|
|
||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,28 +123,22 @@ function parseChunks(data: Uint8Array): Array<{ id: string; data: Uint8Array; of
|
|||||||
export function extractColorFromWebP(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
export function extractColorFromWebP(imageData: ArrayBuffer | Uint8Array): string | undefined {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[extractColorFromWebP] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
if (!isWebP(data)) {
|
if (!isWebP(data)) {
|
||||||
console.error('[extractColorFromWebP] Not a WebP file');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
console.log('[extractColorFromWebP] Found', chunks.length, 'chunks:', chunks.map(c => c.id).join(', '));
|
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
if (chunk.id === 'XMP ') {
|
if (chunk.id === 'XMP ') {
|
||||||
const xmpString = new TextDecoder().decode(chunk.data);
|
const xmpString = new TextDecoder().decode(chunk.data);
|
||||||
const color = extractColorFromXMP(xmpString);
|
const color = extractColorFromXMP(xmpString);
|
||||||
if (color) {
|
if (color) {
|
||||||
console.log('[extractColorFromWebP] Found color:', color);
|
|
||||||
return color;
|
return color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[extractColorFromWebP] No color found');
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,15 +151,11 @@ export function extractColorFromWebP(imageData: ArrayBuffer | Uint8Array): strin
|
|||||||
export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: string): Uint8Array | null {
|
||||||
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
const data = imageData instanceof Uint8Array ? imageData : new Uint8Array(imageData);
|
||||||
|
|
||||||
console.log('[embedColorInWebP] Input size:', data.length, 'bytes');
|
|
||||||
|
|
||||||
if (!isWebP(data)) {
|
if (!isWebP(data)) {
|
||||||
console.error('[embedColorInWebP] Not a WebP file');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const chunks = parseChunks(data);
|
const chunks = parseChunks(data);
|
||||||
console.log('[embedColorInWebP] Found', chunks.length, 'chunks:', chunks.map(c => c.id).join(', '));
|
|
||||||
|
|
||||||
// Find existing XMP chunk with our color
|
// Find existing XMP chunk with our color
|
||||||
let existingXMPChunk: { offset: number; length: number } | null = null;
|
let existingXMPChunk: { offset: number; length: number } | null = null;
|
||||||
@@ -192,14 +182,10 @@ export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: str
|
|||||||
xmpChunk.set(xmpBytes, 8);
|
xmpChunk.set(xmpBytes, 8);
|
||||||
// Padding byte is already 0 from Uint8Array initialization
|
// Padding byte is already 0 from Uint8Array initialization
|
||||||
|
|
||||||
console.log('[embedColorInWebP] Created XMP chunk, size:', xmpChunk.length, 'bytes');
|
|
||||||
console.log('[embedColorInWebP] Color being embedded:', color);
|
|
||||||
|
|
||||||
let newData: Uint8Array;
|
let newData: Uint8Array;
|
||||||
|
|
||||||
if (existingXMPChunk) {
|
if (existingXMPChunk) {
|
||||||
// Replace existing chunk
|
// Replace existing chunk
|
||||||
console.log('[embedColorInWebP] Replacing existing XMP chunk at offset', existingXMPChunk.offset);
|
|
||||||
newData = new Uint8Array(data.length - existingXMPChunk.length + xmpChunk.length);
|
newData = new Uint8Array(data.length - existingXMPChunk.length + xmpChunk.length);
|
||||||
newData.set(data.slice(0, existingXMPChunk.offset), 0);
|
newData.set(data.slice(0, existingXMPChunk.offset), 0);
|
||||||
newData.set(xmpChunk, existingXMPChunk.offset);
|
newData.set(xmpChunk, existingXMPChunk.offset);
|
||||||
@@ -209,7 +195,6 @@ export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: str
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Insert new chunk at end (before any trailing data)
|
// Insert new chunk at end (before any trailing data)
|
||||||
console.log('[embedColorInWebP] Inserting new XMP chunk at end');
|
|
||||||
newData = new Uint8Array(data.length + xmpChunk.length);
|
newData = new Uint8Array(data.length + xmpChunk.length);
|
||||||
newData.set(data, 0);
|
newData.set(data, 0);
|
||||||
newData.set(xmpChunk, data.length);
|
newData.set(xmpChunk, data.length);
|
||||||
@@ -219,8 +204,6 @@ export function embedColorInWebP(imageData: ArrayBuffer | Uint8Array, color: str
|
|||||||
const newFileSize = newData.length - 8; // RIFF size excludes first 8 bytes
|
const newFileSize = newData.length - 8; // RIFF size excludes first 8 bytes
|
||||||
newData.set(writeUint32LE(newFileSize), 4);
|
newData.set(writeUint32LE(newFileSize), 4);
|
||||||
|
|
||||||
console.log('[embedColorInWebP] Output size:', newData.length, 'bytes');
|
|
||||||
|
|
||||||
return newData;
|
return newData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user