frontend lint
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"no-use-before-define": [
|
||||
"error",
|
||||
{ "functions": false, "classes": true, "variables": true }
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off"
|
||||
}
|
||||
}
|
||||
Generated
+2615
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"lint": "echo \"No linting configured\""
|
||||
"lint": "eslint src --ext .js,.jsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
@@ -20,6 +20,9 @@
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-plugin-react": "7.37.1",
|
||||
"eslint-plugin-react-hooks": "4.6.0",
|
||||
"@svgr/webpack": "8.1.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"css-loader": "7.1.2",
|
||||
|
||||
@@ -17,12 +17,8 @@ import PreviewZoomOverlay from './detail/PreviewZoomOverlay';
|
||||
import { getTagColorStyle } from './utils/colors';
|
||||
import './DesktopWorkspace.css';
|
||||
|
||||
const ITEM_WIDTH = 220;
|
||||
const ITEM_HEIGHT = 260;
|
||||
const CANVAS_PADDING = 24;
|
||||
const ROTATION_RANGE = 7;
|
||||
const JITTER_X = 26;
|
||||
const JITTER_Y = 32;
|
||||
const DEFAULT_CANVAS_WIDTH = 1024;
|
||||
const DEFAULT_CANVAS_HEIGHT = 680;
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
@@ -90,7 +86,6 @@ const DesktopPreviewCard = ({
|
||||
prefetch = 3,
|
||||
onNavigatorSnapshot,
|
||||
shouldLoad = true,
|
||||
imageSize = null,
|
||||
}) => {
|
||||
const navigator = useAssetNavigator({
|
||||
document: doc,
|
||||
@@ -230,9 +225,6 @@ const generateInitialLayout = (
|
||||
}
|
||||
|
||||
const shelfOffset = Math.max(shelfWidth, 0);
|
||||
const usableWidth = Math.max(canvasWidth - shelfOffset - padding * 2, 1);
|
||||
const usableHeight = Math.max(canvasHeight - padding * 2, 1);
|
||||
|
||||
const spacingBuffer = Math.max(minSpacing, 0);
|
||||
const placed = [];
|
||||
|
||||
@@ -267,7 +259,7 @@ const generateInitialLayout = (
|
||||
return best;
|
||||
};
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
entries.forEach((entry) => {
|
||||
const width = Number(entry.width) || 0;
|
||||
const height = Number(entry.height) || 0;
|
||||
if (!entry.id || width <= 0 || height <= 0) {
|
||||
@@ -874,18 +866,6 @@ const DesktopWorkspace = ({
|
||||
},
|
||||
[resolvePreviewDimensions],
|
||||
);
|
||||
|
||||
const polygonArea = (points) => {
|
||||
let area = 0;
|
||||
const count = points.length;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const next = (index + 1) % count;
|
||||
area += points[index].x * points[next].y;
|
||||
area -= points[next].x * points[index].y;
|
||||
}
|
||||
return area / 2;
|
||||
};
|
||||
|
||||
const signedDistance = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
||||
|
||||
const clipPolygonWithEdge = (subject, edgeStart, edgeEnd) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
@@ -199,7 +199,6 @@ const PreviewStack = ({
|
||||
onItemActivate,
|
||||
onOpenPreview,
|
||||
onZoomPreview,
|
||||
activeItemId = null,
|
||||
}) => {
|
||||
if (!items.length) {
|
||||
return <span className="meta">{emptyMessage}</span>;
|
||||
@@ -288,7 +287,6 @@ const DetailPanel = ({
|
||||
onTagAdd,
|
||||
onTagRemove,
|
||||
onRegenerateThumbnails,
|
||||
previewEntry,
|
||||
onOpenPreview,
|
||||
onBulkTagAdd,
|
||||
onBulkTagRemove,
|
||||
@@ -296,7 +294,6 @@ const DetailPanel = ({
|
||||
onBulkCorrespondentAdd,
|
||||
onBulkCorrespondentRemove,
|
||||
onPromoteSelection,
|
||||
activePreviewId = null,
|
||||
onUpdateTitle = async () => false,
|
||||
ensureAssetUrl = null,
|
||||
getDocumentAsset = () => null,
|
||||
@@ -542,9 +539,6 @@ const DetailPanel = ({
|
||||
const topCardinality = stackPreviewNavigator.cardinality;
|
||||
const topEffectiveCardinality = topCardinality || (stackPreviewNavigator.currentUrl ? 1 : 0);
|
||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||
const topOrdinal = stackPreviewNavigator.ordinal;
|
||||
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
||||
const topCanGoNext = stackPreviewNavigator.canGoNext;
|
||||
|
||||
const bulkTagUnion = useMemo(() => {
|
||||
if (!selectedDocuments.length) return [];
|
||||
@@ -888,7 +882,6 @@ const DetailPanel = ({
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onZoomPreview={handleSingleZoom}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
{hasPreviewImage && (effectiveCardinality > 1 || canGoPrev || canGoNext) ? (
|
||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||
@@ -1093,9 +1086,7 @@ const DetailPanel = ({
|
||||
const renderBulk = () => {
|
||||
const countLabel = `${selectedCount} document${selectedCount === 1 ? '' : 's'}`;
|
||||
const sizeLabel = stackTotalSizeBytes ? formatFileSize(stackTotalSizeBytes) : '—';
|
||||
const topDoc = stackTopDocument;
|
||||
const topDocIdLocal = topDocId;
|
||||
const topOrdinal = stackPreviewNavigator.ordinal;
|
||||
const topCardinalityLocal = topEffectiveCardinality;
|
||||
const topHasPreview = Boolean(stackPreviewNavigator.currentUrl);
|
||||
const topCanGoPrev = stackPreviewNavigator.canGoPrev;
|
||||
@@ -1115,7 +1106,6 @@ const DetailPanel = ({
|
||||
onItemActivate={handlePreviewActivate}
|
||||
onOpenPreview={onOpenPreview}
|
||||
onZoomPreview={handleStackZoom}
|
||||
activeItemId={activePreviewId}
|
||||
/>
|
||||
{topDocIdLocal && topHasPreview && (topCardinalityLocal > 1 || topCanGoPrev || topCanGoNext) ? (
|
||||
<div className="preview-pane__nav preview-pane__nav--overlay">
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
|
||||
const TAG_MIME_TYPES = ['application/x-papercrate-tag', 'text/papercrate-tag'];
|
||||
const DEFAULT_GRID_ICON_SIZE = 144;
|
||||
const DEFAULT_GRID_TITLE_SIZE = '11px';
|
||||
const LIST_ICON_SIZE = 48;
|
||||
|
||||
const getPageCount = (doc) =>
|
||||
@@ -198,7 +197,6 @@ const DocumentThumbnailImage = ({
|
||||
|
||||
const DocumentsTable = ({
|
||||
currentFolderName,
|
||||
breadcrumbs,
|
||||
onRefresh,
|
||||
onShowSkeuoWorkspace = () => {},
|
||||
onRequestCreateFolder,
|
||||
@@ -206,7 +204,6 @@ const DocumentsTable = ({
|
||||
subfolders,
|
||||
documents,
|
||||
searchResults,
|
||||
isFilterActive,
|
||||
onFolderSelect,
|
||||
onFolderDrop,
|
||||
onFolderDragOver,
|
||||
@@ -220,7 +217,6 @@ const DocumentsTable = ({
|
||||
onDocumentRowClick,
|
||||
onDocumentOpen,
|
||||
selectedDocumentIds,
|
||||
focusedDocumentId,
|
||||
focusedRowKey,
|
||||
draggingDocumentIds = [],
|
||||
onDocumentDragStart,
|
||||
|
||||
+1
-248
@@ -397,33 +397,6 @@ const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
|
||||
</main>
|
||||
);
|
||||
|
||||
const MainContentShell = ({
|
||||
className,
|
||||
header = {},
|
||||
sidebarToggleButton = null,
|
||||
children,
|
||||
}) => {
|
||||
const { title = '', subtitle = null, leading = null, actions = null } = header || {};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="panel-header main-content__header">
|
||||
<div className="panel-actions main-content__actions">
|
||||
{sidebarToggleButton}
|
||||
{leading}
|
||||
<h2 className="main-content__title">
|
||||
{title}
|
||||
{subtitle ? <span className="main-content__subtitle">{subtitle}</span> : null}
|
||||
</h2>
|
||||
<div className="spacer" />
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppLayout = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -445,9 +418,6 @@ const AppLayout = () => {
|
||||
({ message, variant }) => setStatusMessage(message, variant),
|
||||
[setStatusMessage],
|
||||
);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
|
||||
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
|
||||
const reportApiError = useApiError({
|
||||
onReport: handleApiReport,
|
||||
});
|
||||
@@ -616,12 +586,6 @@ const AppLayout = () => {
|
||||
}
|
||||
const tagManager = tagManagerRef.current;
|
||||
|
||||
|
||||
const buildTagPayload = useCallback(
|
||||
({ label, color } = {}) => tagManager.buildPayload({ label, color }),
|
||||
[tagManager],
|
||||
);
|
||||
|
||||
const getDocumentAsset = useCallback((doc, type) => {
|
||||
if (!doc || !type) return null;
|
||||
return getAssetFromVersion(doc.current_version || null, type);
|
||||
@@ -1247,16 +1211,6 @@ const AppLayout = () => {
|
||||
[selectedRowKeys, updateSelectionOrder],
|
||||
);
|
||||
|
||||
const visibleSelectedCount = useMemo(
|
||||
() => selectedDocumentIds.filter((id) => visibleDocumentIds.includes(id)).length,
|
||||
[selectedDocumentIds, visibleDocumentIds],
|
||||
);
|
||||
|
||||
const allDocumentsSelected =
|
||||
visibleDocumentIds.length > 0 &&
|
||||
visibleSelectedCount === visibleDocumentIds.length;
|
||||
const someDocumentsSelected =
|
||||
visibleSelectedCount > 0 && !allDocumentsSelected;
|
||||
|
||||
const folderOptions = useMemo(() => {
|
||||
const cache = new Map();
|
||||
@@ -2498,73 +2452,6 @@ const AppLayout = () => {
|
||||
};
|
||||
}, [appStatus, appDispatch, initializeAfterLogin]);
|
||||
|
||||
const handleBulkMoveSubmit = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedDocumentIds.length) {
|
||||
setStatusMessage('Select documents before moving.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const target = form.get('target')?.toString() || 'root';
|
||||
const folderId = target === 'root' ? null : target;
|
||||
|
||||
if (!refreshOnly) {
|
||||
setLoading(true);
|
||||
}
|
||||
try {
|
||||
await api.post('/documents/bulk/move', {
|
||||
document_ids: selectedDocumentIds,
|
||||
folder_id: folderId,
|
||||
});
|
||||
|
||||
const count = selectedDocumentIds.length;
|
||||
const suffix = count === 1 ? '' : 's';
|
||||
const folderLabel =
|
||||
folderId === null
|
||||
? DEFAULT_FOLDER_NAME
|
||||
: folderLabelMap.get(target) || 'target folder';
|
||||
setStatusMessage(
|
||||
`Moved ${count} document${suffix} to ${folderLabel}.`,
|
||||
'success',
|
||||
);
|
||||
|
||||
applySelection([], { anchor: null });
|
||||
|
||||
await refreshCurrentFolder();
|
||||
if (folderId && folderId !== selectedFolder) {
|
||||
await ensureFolderData(folderId, { force: true, prefetchDepth: 1 });
|
||||
}
|
||||
|
||||
event.currentTarget.reset();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.error || 'Failed to move documents.';
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
api,
|
||||
selectedDocumentIds,
|
||||
folderLabelMap,
|
||||
applySelection,
|
||||
refreshCurrentFolder,
|
||||
ensureFolderData,
|
||||
notifyApiError,
|
||||
selectedFolder,
|
||||
setStatusMessage,
|
||||
],
|
||||
);
|
||||
|
||||
const parseTagInput = useCallback((value) => {
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}, []);
|
||||
|
||||
const bulkTagOperation = useCallback(
|
||||
async ({ labels, action, documentIds }) => {
|
||||
const normalized = labels.map((label) => label.trim()).filter((label) => label.length > 0);
|
||||
@@ -2650,81 +2537,6 @@ const AppLayout = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkTagAdd = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedDocumentIds.length) {
|
||||
setStatusMessage('Select documents before assigning tags.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const raw = form.get('tags')?.toString().trim() || '';
|
||||
const labels = parseTagInput(raw);
|
||||
if (!labels.length) {
|
||||
setStatusMessage('Enter at least one tag label.', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await bulkTagOperation({ labels, action: 'add' });
|
||||
if (result?.ok) {
|
||||
const { tagCount, docsCount } = result;
|
||||
setStatusMessage(
|
||||
`Assigned ${tagCount} tag${tagCount === 1 ? '' : 's'} to ${docsCount} document${
|
||||
docsCount === 1 ? '' : 's'
|
||||
}.`,
|
||||
'success',
|
||||
);
|
||||
event.currentTarget.reset();
|
||||
} else if (result?.reason === 'no-labels') {
|
||||
setStatusMessage('Enter at least one tag label.', 'error');
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedDocumentIds,
|
||||
setStatusMessage,
|
||||
parseTagInput,
|
||||
bulkTagOperation,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkTagRemove = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedDocumentIds.length) {
|
||||
setStatusMessage('Select documents before removing tags.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const form = new FormData(event.currentTarget);
|
||||
const raw = form.get('tags')?.toString().trim() || '';
|
||||
const labels = parseTagInput(raw);
|
||||
if (!labels.length) {
|
||||
setStatusMessage('Enter at least one tag label to remove.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bulkTagOperation({ labels, action: 'remove' });
|
||||
if (result?.ok) {
|
||||
const { docsCount } = result;
|
||||
setStatusMessage(
|
||||
`Removed tags from ${docsCount} document${docsCount === 1 ? '' : 's'}.`,
|
||||
'success',
|
||||
);
|
||||
event.currentTarget.reset();
|
||||
} else if (result?.reason === 'no-labels') {
|
||||
setStatusMessage('Enter at least one tag label to remove.', 'error');
|
||||
} else if (result?.reason === 'tag-missing') {
|
||||
setStatusMessage(`Tag “${result.label}” not found.`, 'error');
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedDocumentIds,
|
||||
setStatusMessage,
|
||||
parseTagInput,
|
||||
bulkTagOperation,
|
||||
],
|
||||
);
|
||||
|
||||
const handleBulkTagAddFromDetail = useCallback(
|
||||
async ({ label, input, documentIds }) => {
|
||||
const trimmed = (label || '').trim();
|
||||
@@ -3371,7 +3183,7 @@ const AppLayout = () => {
|
||||
}),
|
||||
);
|
||||
|
||||
Array.from(dataTransfer.files || []).forEach((file, index) => {
|
||||
Array.from(dataTransfer.files || []).forEach((file) => {
|
||||
if (!file) return;
|
||||
// FileList entry suppressed
|
||||
const relativePath =
|
||||
@@ -4554,54 +4366,6 @@ const AppLayout = () => {
|
||||
};
|
||||
}, [handleTagRemove, setTagRemovalCursor]);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
const payload = {
|
||||
username: form.get('username')?.toString().trim(),
|
||||
password: form.get('password')?.toString() || '',
|
||||
};
|
||||
if (!payload.username || !payload.password) {
|
||||
setStatusMessage('Username and password are required.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||
const { data } = await api.post('/auth/login', payload);
|
||||
|
||||
if (data?.access_token && Array.isArray(data?.tenants)) {
|
||||
appDispatch({
|
||||
type: 'TENANT_SELECTION_REQUIRED',
|
||||
selectionToken: data.access_token,
|
||||
tenants: data.tenants,
|
||||
});
|
||||
setStatusMessage('Select a tenant to continue.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data?.access_token) {
|
||||
throw new Error('Invalid login response.');
|
||||
}
|
||||
|
||||
appDispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
token: data.access_token,
|
||||
tenant: data.tenant || null,
|
||||
});
|
||||
setStatusMessage('Login successful.', 'success');
|
||||
} catch (error) {
|
||||
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
|
||||
appDispatch({ type: 'LOGIN_FAILURE', error: message });
|
||||
notifyApiError(error, message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[appDispatch, notifyApiError, setStatusMessage],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -5333,7 +5097,6 @@ const AppLayout = () => {
|
||||
isFilterActive,
|
||||
onLogout: handleLogout,
|
||||
status,
|
||||
onCollapse: collapseSidebar,
|
||||
tenantSlug,
|
||||
tenants: tenantOptions,
|
||||
activeTenantId: currentTenantId,
|
||||
@@ -5341,16 +5104,6 @@ const AppLayout = () => {
|
||||
onOpenSettings: openSettingsModal,
|
||||
};
|
||||
|
||||
const sidebarPropsWithActions = useMemo(
|
||||
() => ({
|
||||
...sidebarProps,
|
||||
onManageTags: openTagsModal,
|
||||
onManageCorrespondents: openCorrespondentsModal,
|
||||
onCollapse: collapseSidebar,
|
||||
}),
|
||||
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
|
||||
);
|
||||
|
||||
const handleDetailPanelClose = useCallback(() => {
|
||||
setDetailPanelOpen(false);
|
||||
clearDocumentSelection();
|
||||
|
||||
@@ -151,9 +151,6 @@ const Sidebar = ({
|
||||
onSearchSubmit,
|
||||
onSearchClear,
|
||||
isFilterActive,
|
||||
appStatus,
|
||||
loading,
|
||||
previewActive,
|
||||
onLogout,
|
||||
status,
|
||||
onCollapse,
|
||||
|
||||
@@ -4,37 +4,6 @@ const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
||||
|
||||
const clampRange = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const gammaEncode = (channel) =>
|
||||
channel <= 0.0031308 ? 12.92 * channel : 1.055 * Math.pow(channel, 1 / 2.4) - 0.055;
|
||||
|
||||
const oklchToHex = (l, c, h) => {
|
||||
const hr = (h * Math.PI) / 180;
|
||||
const a = Math.cos(hr) * c;
|
||||
const b = Math.sin(hr) * c;
|
||||
|
||||
const l1 = l + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m1 = l - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s1 = l - 0.0894841775 * a - 1.291485548 * b;
|
||||
|
||||
const l3 = l1 ** 3;
|
||||
const m3 = m1 ** 3;
|
||||
const s3 = s1 ** 3;
|
||||
|
||||
const r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
||||
const g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
||||
const bLin = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.707614701 * s3;
|
||||
|
||||
if ([r, g, bLin].some((channel) => channel < 0 || channel > 1)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sr = Math.round(clamp01(gammaEncode(r)) * 255);
|
||||
const sg = Math.round(clamp01(gammaEncode(g)) * 255);
|
||||
const sb = Math.round(clamp01(gammaEncode(bLin)) * 255);
|
||||
|
||||
return `#${((sr << 16) | (sg << 8) | sb).toString(16).padStart(6, '0')}`;
|
||||
};
|
||||
|
||||
const hslToHex = (h, s, l) => {
|
||||
const normalizedH = ((h % 360) + 360) % 360;
|
||||
const sat = clamp01(s);
|
||||
|
||||
Reference in New Issue
Block a user