修复了一点bug,然后增加了图层拆分更多的功能
This commit is contained in:
1 parent
1305a9cdd8
commit
ce8372e878
31 files changed
+2567
-391
No files matched your search
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||
import { ArrowDown, ArrowUp, Check, CircleDollarSign, Download, Eye, EyeOff, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Move, Music, Pencil, Plus, RefreshCw, RotateCcw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode, type RefObject } from "react";
|
||||
import { Check, CircleDollarSign, Download, Film, ImageIcon, ImagePlus, Info, Layers3, Loader2, Music, Pencil, Plus, RefreshCw, Save, ScanLine, Send, Upload, WandSparkles, X } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { clampPage, pageItems, Pagination } from "@/components/pagination";
|
||||
import { SeedreamWorkspace } from "@/components/seedream-workspace";
|
||||
import { SeedreamLayerResultViewer, type SeedreamLayerOutput } from "@/components/seedream-layer-result-viewer";
|
||||
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||
import {
|
||||
SEEDANCE_20_MODEL,
|
||||
@@ -28,9 +29,7 @@ import {
|
||||
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
|
||||
import { formatBillingAmount } from "@/lib/billing";
|
||||
import { assetContentUrl, assetPreviewUrl, materialPreviewUrl, storedAssetPreviewUrl } from "@/lib/client/asset-urls";
|
||||
import { downloadSeedreamLayerComposition } from "@/lib/client/seedream-layer-compose";
|
||||
import { createSeedreamMarkedFile } from "@/lib/client/seedream-markup";
|
||||
import { downloadFilesAsZip } from "@/lib/client/zip-download";
|
||||
import {
|
||||
buildSeedreamInteractivePrompt,
|
||||
buildSeedreamLayerPrompt,
|
||||
@@ -2017,7 +2016,7 @@ function TaskDetailModal({
|
||||
</div>
|
||||
</div>
|
||||
{layerOutputs.length ? (
|
||||
<SeedreamLayerResultViewer jobId={job.id} outputs={layerOutputs} />
|
||||
<SeedreamLayerResultViewer jobId={job.id} outputs={layerOutputs} assetLibrary={[...assets.values()]} />
|
||||
) : outputAssets.length ? (
|
||||
<div className="task-detail-output-grid">
|
||||
{outputAssets.map((asset) => (
|
||||
@@ -2128,365 +2127,6 @@ function TaskDetailModal({
|
||||
);
|
||||
}
|
||||
|
||||
type SeedreamLayerOutput = {
|
||||
asset: Asset;
|
||||
zIndex: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
size?: [number, number];
|
||||
boundingBox?: {
|
||||
absolute?: [number, number, number, number];
|
||||
normalized?: [number, number, number, number];
|
||||
};
|
||||
};
|
||||
|
||||
function SeedreamLayerResultViewer({ jobId, outputs }: { jobId: string; outputs: SeedreamLayerOutput[] }) {
|
||||
const ordered = useMemo(() => [...outputs].sort((a, b) => a.zIndex - b.zIndex), [outputs]);
|
||||
const base = ordered.find((output) => output.zIndex === 0) || ordered[0];
|
||||
const layers = ordered.filter((output) => output !== base);
|
||||
const baseSize = base.size || [1, 1];
|
||||
const layerSignature = `${jobId}:${ordered.map((output) => [output.asset.id, output.zIndex, output.size, output.boundingBox?.normalized, output.boundingBox?.absolute].join(":"))}`;
|
||||
const [hiddenIds, setHiddenIds] = useState<Set<string>>(() => new Set());
|
||||
const [selectedId, setSelectedId] = useState<string | null>(layers[0]?.asset.id ?? base.asset.id);
|
||||
const [layerOrder, setLayerOrder] = useState<string[]>(() => layers.map((layer) => layer.asset.id));
|
||||
const [transforms, setTransforms] = useState<Record<string, SeedreamLayerBox>>(() => initialSeedreamLayerTransforms(layers, baseSize));
|
||||
const [interaction, setInteraction] = useState<SeedreamLayerInteraction | null>(null);
|
||||
const [zipDownloading, setZipDownloading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [downloadError, setDownloadError] = useState<string | null>(null);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const layerById = useMemo(() => new Map(layers.map((layer) => [layer.asset.id, layer])), [layers]);
|
||||
const displayLayers = layerOrder.map((id) => layerById.get(id)).filter((layer): layer is SeedreamLayerOutput => Boolean(layer));
|
||||
const selectedLayer = selectedId ? layerById.get(selectedId) : undefined;
|
||||
const selectedTransform = selectedLayer ? transforms[selectedLayer.asset.id] : undefined;
|
||||
const listOutputs = [...displayLayers].reverse().concat(base);
|
||||
const previewMaximumWidth = Math.max(1, 500 * baseSize[0] / baseSize[1]);
|
||||
|
||||
useEffect(() => {
|
||||
setHiddenIds(new Set());
|
||||
setSelectedId(layers[0]?.asset.id ?? base.asset.id);
|
||||
setLayerOrder(layers.map((layer) => layer.asset.id));
|
||||
setTransforms(initialSeedreamLayerTransforms(layers, baseSize));
|
||||
setInteraction(null);
|
||||
setDownloadError(null);
|
||||
}, [layerSignature]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setHiddenIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadAll() {
|
||||
setZipDownloading(true);
|
||||
setDownloadError(null);
|
||||
try {
|
||||
await downloadFilesAsZip(ordered.map((output) => ({
|
||||
name: layerDownloadName(output),
|
||||
url: assetDownloadUrl(output.asset)
|
||||
})), `seedream-layers-${jobId}.zip`);
|
||||
} catch (err) {
|
||||
setDownloadError(err instanceof Error ? err.message : "打包下载失败");
|
||||
} finally {
|
||||
setZipDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportComposition() {
|
||||
setExporting(true);
|
||||
setDownloadError(null);
|
||||
try {
|
||||
await downloadSeedreamLayerComposition({
|
||||
width: baseSize[0],
|
||||
height: baseSize[1],
|
||||
fileName: `seedream-composition-${jobId}.png`,
|
||||
items: [base, ...displayLayers].map((output) => ({
|
||||
url: assetContentUrl(output.asset),
|
||||
visible: !hiddenIds.has(output.asset.id),
|
||||
box: output === base ? fullSeedreamLayerBox : transforms[output.asset.id] || fullSeedreamLayerBox
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
setDownloadError(err instanceof Error ? err.message : "导出合成图片失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
setHiddenIds(new Set());
|
||||
setLayerOrder(layers.map((layer) => layer.asset.id));
|
||||
setTransforms(initialSeedreamLayerTransforms(layers, baseSize));
|
||||
setSelectedId(layers[0]?.asset.id ?? base.asset.id);
|
||||
setInteraction(null);
|
||||
setDownloadError(null);
|
||||
}
|
||||
|
||||
function reorderLayer(id: string, direction: -1 | 1) {
|
||||
setLayerOrder((current) => {
|
||||
const index = current.indexOf(id);
|
||||
const nextIndex = index + direction;
|
||||
if (index < 0 || nextIndex < 0 || nextIndex >= current.length) return current;
|
||||
const next = [...current];
|
||||
[next[index], next[nextIndex]] = [next[nextIndex], next[index]];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function scaleLayer(id: string, factor: number) {
|
||||
setTransforms((current) => {
|
||||
const box = current[id];
|
||||
return box ? { ...current, [id]: scaleSeedreamLayerBox(box, factor) } : current;
|
||||
});
|
||||
}
|
||||
|
||||
function startInteraction(event: ReactPointerEvent<HTMLElement>, id: string, mode: SeedreamLayerInteraction["mode"]) {
|
||||
const box = transforms[id];
|
||||
if (!box) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setSelectedId(id);
|
||||
setInteraction({ id, mode, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startBox: box });
|
||||
}
|
||||
|
||||
function continueInteraction(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (!interaction || interaction.pointerId !== event.pointerId || !previewRef.current) return;
|
||||
event.preventDefault();
|
||||
const bounds = previewRef.current.getBoundingClientRect();
|
||||
if (!bounds.width || !bounds.height) return;
|
||||
const deltaX = (event.clientX - interaction.startX) / bounds.width * 1000;
|
||||
const deltaY = (event.clientY - interaction.startY) / bounds.height * 1000;
|
||||
const nextBox = interaction.mode === "move"
|
||||
? moveSeedreamLayerBox(interaction.startBox, deltaX, deltaY)
|
||||
: resizeSeedreamLayerBox(interaction.startBox, deltaX, deltaY);
|
||||
setTransforms((current) => ({ ...current, [interaction.id]: nextBox }));
|
||||
}
|
||||
|
||||
function finishInteraction(event: ReactPointerEvent<HTMLDivElement>) {
|
||||
if (interaction?.pointerId === event.pointerId) setInteraction(null);
|
||||
}
|
||||
|
||||
function handleLayerKeyDown(event: KeyboardEvent<HTMLButtonElement>, id: string) {
|
||||
const direction = event.key === "ArrowLeft"
|
||||
? [-1, 0]
|
||||
: event.key === "ArrowRight"
|
||||
? [1, 0]
|
||||
: event.key === "ArrowUp"
|
||||
? [0, -1]
|
||||
: event.key === "ArrowDown"
|
||||
? [0, 1]
|
||||
: null;
|
||||
if (!direction) return;
|
||||
event.preventDefault();
|
||||
const step = event.shiftKey ? 50 : 10;
|
||||
setTransforms((current) => {
|
||||
const box = current[id];
|
||||
return box ? { ...current, [id]: moveSeedreamLayerBox(box, direction[0] * step, direction[1] * step) } : current;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="seedream-layer-viewer">
|
||||
<div className="seedream-layer-preview-column">
|
||||
<div className="seedream-layer-editor-toolbar">
|
||||
<span><Move size={13} />拖动图层,右下角等比缩放;方向键可微调</span>
|
||||
<div>
|
||||
<button className="button mini-button" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && scaleLayer(selectedLayer.asset.id, 0.9)}>缩小</button>
|
||||
<button className="button mini-button" type="button" disabled={!selectedLayer} onClick={() => selectedLayer && scaleLayer(selectedLayer.asset.id, 1.1)}>放大</button>
|
||||
<button className="button mini-button" type="button" onClick={resetEditor}><RotateCcw size={13} />重置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={previewRef}
|
||||
className={clsx("seedream-layer-preview", interaction && "is-editing")}
|
||||
style={{ aspectRatio: `${baseSize[0]} / ${baseSize[1]}`, width: `min(100%, ${previewMaximumWidth}px)` }}
|
||||
onPointerMove={continueInteraction}
|
||||
onPointerUp={finishInteraction}
|
||||
onPointerCancel={finishInteraction}
|
||||
>
|
||||
{!hiddenIds.has(base.asset.id) ? <img className="seedream-layer-base" src={assetPreviewUrl(base.asset)} alt={base.name} draggable={false} /> : null}
|
||||
{displayLayers.map((layer, stackIndex) => {
|
||||
const box = transforms[layer.asset.id];
|
||||
if (!box || hiddenIds.has(layer.asset.id)) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={layer.asset.id}
|
||||
className={clsx("seedream-layer-overlay", selectedId === layer.asset.id && "selected")}
|
||||
aria-label={`移动${layer.name}图层`}
|
||||
style={{
|
||||
left: `${box[0] / 10}%`,
|
||||
top: `${box[1] / 10}%`,
|
||||
width: `${(box[2] - box[0]) / 10}%`,
|
||||
height: `${(box[3] - box[1]) / 10}%`,
|
||||
zIndex: stackIndex + 1
|
||||
}}
|
||||
onPointerDown={(event) => startInteraction(event, layer.asset.id, "move")}
|
||||
onKeyDown={(event) => handleLayerKeyDown(event, layer.asset.id)}
|
||||
onClick={() => setSelectedId(layer.asset.id)}
|
||||
>
|
||||
<img src={assetPreviewUrl(layer.asset)} alt="" draggable={false} />
|
||||
<span className="seedream-layer-resize-handle" aria-hidden="true" onPointerDown={(event) => startInteraction(event, layer.asset.id, "resize")} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedTransform ? (
|
||||
<div className="seedream-layer-inspector" aria-live="polite">
|
||||
<strong>{selectedLayer?.name}</strong>
|
||||
<span>X {Math.round(selectedTransform[0] / 10)}%</span>
|
||||
<span>Y {Math.round(selectedTransform[1] / 10)}%</span>
|
||||
<span>宽 {Math.round((selectedTransform[2] - selectedTransform[0]) / 10)}%</span>
|
||||
<span>高 {Math.round((selectedTransform[3] - selectedTransform[1]) / 10)}%</span>
|
||||
</div>
|
||||
) : <div className="seedream-layer-inspector"><strong>底图</strong><span>固定在画布底部</span></div>}
|
||||
<div className="seedream-layer-preview-actions">
|
||||
<span>合成导出采用当前显隐、位置、尺寸和层级</span>
|
||||
<div>
|
||||
<button className="button mini-button" type="button" disabled={exporting} onClick={() => void exportComposition()}>
|
||||
{exporting ? <Loader2 className="spin" size={14} /> : <Download size={14} />}
|
||||
导出合成 PNG
|
||||
</button>
|
||||
<button className="button mini-button" type="button" disabled={zipDownloading} onClick={() => void downloadAll()}>
|
||||
{zipDownloading ? <Loader2 className="spin" size={14} /> : <Download size={14} />}
|
||||
原始图层 ZIP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{downloadError ? <div className="seedream-layer-download-error" role="alert">{downloadError}</div> : null}
|
||||
</div>
|
||||
<div className="seedream-layer-list" aria-label="拆分图层列表">
|
||||
<div className="seedream-layer-list-head"><span>图层</span><small>上方图层优先显示</small></div>
|
||||
{listOutputs.map((output) => {
|
||||
const isBase = output === base;
|
||||
const visible = !hiddenIds.has(output.asset.id);
|
||||
const orderIndex = layerOrder.indexOf(output.asset.id);
|
||||
return (
|
||||
<article
|
||||
className={clsx(selectedId === output.asset.id && "active")}
|
||||
key={output.asset.id}
|
||||
tabIndex={0}
|
||||
aria-current={selectedId === output.asset.id ? "true" : undefined}
|
||||
onClick={() => setSelectedId(output.asset.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedId(output.asset.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button className="seedream-layer-visibility" type="button" title={visible ? "隐藏图层" : "显示图层"} aria-label={visible ? `隐藏${output.name}` : `显示${output.name}`} onClick={(event) => { event.stopPropagation(); toggle(output.asset.id); }}>
|
||||
{visible ? <Eye size={15} /> : <EyeOff size={15} />}
|
||||
</button>
|
||||
<span className="seedream-layer-thumb"><img src={assetPreviewUrl(output.asset)} alt="" /></span>
|
||||
<span className="seedream-layer-copy">
|
||||
<strong>{isBase ? "底图" : output.name}</strong>
|
||||
<small>{output.description || (isBase ? "固定画布" : `原始层级 ${output.zIndex}`)}</small>
|
||||
</span>
|
||||
{isBase ? <span className="seedream-layer-fixed">固定</span> : (
|
||||
<span className="seedream-layer-order-actions">
|
||||
<button type="button" title="上移一层" aria-label={`上移${output.name}`} disabled={orderIndex === layerOrder.length - 1} onClick={(event) => { event.stopPropagation(); reorderLayer(output.asset.id, 1); }}><ArrowUp size={13} /></button>
|
||||
<button type="button" title="下移一层" aria-label={`下移${output.name}`} disabled={orderIndex === 0} onClick={(event) => { event.stopPropagation(); reorderLayer(output.asset.id, -1); }}><ArrowDown size={13} /></button>
|
||||
</span>
|
||||
)}
|
||||
<a className="icon-button" href={assetDownloadUrl(output.asset)} title={`下载 ${output.name}`} aria-label={`下载 ${output.name}`} onClick={(event) => event.stopPropagation()}>
|
||||
<Download size={14} />
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SeedreamLayerBox = [number, number, number, number];
|
||||
|
||||
type SeedreamLayerInteraction = {
|
||||
id: string;
|
||||
mode: "move" | "resize";
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startBox: SeedreamLayerBox;
|
||||
};
|
||||
|
||||
const fullSeedreamLayerBox: SeedreamLayerBox = [0, 0, 1000, 1000];
|
||||
|
||||
function initialSeedreamLayerTransforms(layers: SeedreamLayerOutput[], baseSize: [number, number]) {
|
||||
return Object.fromEntries(layers.map((layer, index) => [layer.asset.id, initialSeedreamLayerBox(layer, baseSize, index)])) as Record<string, SeedreamLayerBox>;
|
||||
}
|
||||
|
||||
function initialSeedreamLayerBox(layer: SeedreamLayerOutput, baseSize: [number, number], index: number): SeedreamLayerBox {
|
||||
const normalized = layer.boundingBox?.normalized;
|
||||
if (normalized) return sanitizeSeedreamLayerBox(normalized);
|
||||
const absolute = layer.boundingBox?.absolute;
|
||||
if (absolute && baseSize[0] > 1 && baseSize[1] > 1) {
|
||||
return sanitizeSeedreamLayerBox([
|
||||
absolute[0] / baseSize[0] * 1000,
|
||||
absolute[1] / baseSize[1] * 1000,
|
||||
absolute[2] / baseSize[0] * 1000,
|
||||
absolute[3] / baseSize[1] * 1000
|
||||
]);
|
||||
}
|
||||
if (layer.size && baseSize[0] > 1 && baseSize[1] > 1) {
|
||||
const width = Math.min(900, Math.max(80, layer.size[0] / baseSize[0] * 1000));
|
||||
const height = Math.min(900, Math.max(80, layer.size[1] / baseSize[1] * 1000));
|
||||
const offset = Math.min(index * 22, 120);
|
||||
return sanitizeSeedreamLayerBox([(1000 - width) / 2 + offset, (1000 - height) / 2 + offset, (1000 + width) / 2 + offset, (1000 + height) / 2 + offset]);
|
||||
}
|
||||
const offset = Math.min(index * 25, 150);
|
||||
return [150 + offset, 150 + offset, 850, 850];
|
||||
}
|
||||
|
||||
function sanitizeSeedreamLayerBox(value: SeedreamLayerBox): SeedreamLayerBox {
|
||||
const scale = Math.max(...value.map((item) => Math.abs(item))) <= 1 ? 1000 : 1;
|
||||
const left = clampNumber(value[0] * scale, 0, 980);
|
||||
const top = clampNumber(value[1] * scale, 0, 980);
|
||||
const right = clampNumber(value[2] * scale, left + 20, 1000);
|
||||
const bottom = clampNumber(value[3] * scale, top + 20, 1000);
|
||||
return [left, top, right, bottom];
|
||||
}
|
||||
|
||||
function moveSeedreamLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox {
|
||||
const width = box[2] - box[0];
|
||||
const height = box[3] - box[1];
|
||||
const left = clampNumber(box[0] + deltaX, 0, 1000 - width);
|
||||
const top = clampNumber(box[1] + deltaY, 0, 1000 - height);
|
||||
return [left, top, left + width, top + height];
|
||||
}
|
||||
|
||||
function resizeSeedreamLayerBox(box: SeedreamLayerBox, deltaX: number, deltaY: number): SeedreamLayerBox {
|
||||
const width = box[2] - box[0];
|
||||
const height = box[3] - box[1];
|
||||
const requestedFactor = Math.max((width + deltaX) / width, (height + deltaY) / height);
|
||||
const minimumFactor = Math.max(20 / width, 20 / height);
|
||||
const maximumFactor = Math.min((1000 - box[0]) / width, (1000 - box[1]) / height);
|
||||
const factor = clampNumber(requestedFactor, minimumFactor, maximumFactor);
|
||||
return [box[0], box[1], box[0] + width * factor, box[1] + height * factor];
|
||||
}
|
||||
|
||||
function scaleSeedreamLayerBox(box: SeedreamLayerBox, requestedFactor: number): SeedreamLayerBox {
|
||||
const width = box[2] - box[0];
|
||||
const height = box[3] - box[1];
|
||||
const factor = clampNumber(requestedFactor, Math.max(20 / width, 20 / height), Math.min(1000 / width, 1000 / height));
|
||||
const nextWidth = width * factor;
|
||||
const nextHeight = height * factor;
|
||||
const centerX = (box[0] + box[2]) / 2;
|
||||
const centerY = (box[1] + box[3]) / 2;
|
||||
const left = clampNumber(centerX - nextWidth / 2, 0, 1000 - nextWidth);
|
||||
const top = clampNumber(centerY - nextHeight / 2, 0, 1000 - nextHeight);
|
||||
return [left, top, left + nextWidth, top + nextHeight];
|
||||
}
|
||||
|
||||
function clampNumber(value: number, minimum: number, maximum: number) {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
function seedreamLayerOutputs(job: GenerationJob, outputAssets: Asset[]): SeedreamLayerOutput[] {
|
||||
const response = asRecord(job.responsePayload);
|
||||
const raw = asRecord(response.raw);
|
||||
@@ -2510,12 +2150,6 @@ function seedreamLayerOutputs(job: GenerationJob, outputAssets: Asset[]): Seedre
|
||||
});
|
||||
}
|
||||
|
||||
function layerDownloadName(output: SeedreamLayerOutput) {
|
||||
const extension = output.asset.name.match(/\.[a-z0-9]+$/i)?.[0] || (output.zIndex === 0 ? ".jpg" : ".png");
|
||||
const label = output.zIndex === 0 ? "00-base" : `${String(output.zIndex).padStart(2, "0")}-${output.name}`;
|
||||
return `${label}${extension}`;
|
||||
}
|
||||
|
||||
function coordinateTuple(value: unknown): [number, number, number, number] | undefined {
|
||||
if (!Array.isArray(value) || value.length !== 4) return undefined;
|
||||
const numbers = value.map(numericValue);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user