2026-05-19-23-47-31 优化逆向分割映射视图
This commit is contained in:
@@ -5,12 +5,14 @@ import {
|
||||
Download,
|
||||
Rotate3d,
|
||||
AlertCircle,
|
||||
Play,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
Layers,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import * as THREE from 'three';
|
||||
import { DicomFusionVolume, ModuleStyle, Project } from '../types';
|
||||
import { DicomFusionVolume, DicomPreview, ModuleStyle, Project } from '../types';
|
||||
import { api, downloadMask } from '../lib/api';
|
||||
|
||||
interface ModelPose {
|
||||
@@ -792,9 +794,398 @@ function CutSectionPreview({
|
||||
);
|
||||
}
|
||||
|
||||
interface ModelBounds {
|
||||
min: { x: number; y: number; z: number };
|
||||
max: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
function getPayloadBounds(payload: ModelPreviewPayload): ModelBounds | null {
|
||||
if (payload.bounds) {
|
||||
return payload.bounds;
|
||||
}
|
||||
|
||||
if (payload.vertices.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bounds: ModelBounds = {
|
||||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||
};
|
||||
|
||||
for (let index = 0; index < payload.vertices.length; index += 3) {
|
||||
const x = payload.vertices[index];
|
||||
const y = payload.vertices[index + 1];
|
||||
const z = payload.vertices[index + 2];
|
||||
bounds.min.x = Math.min(bounds.min.x, x);
|
||||
bounds.min.y = Math.min(bounds.min.y, y);
|
||||
bounds.min.z = Math.min(bounds.min.z, z);
|
||||
bounds.max.x = Math.max(bounds.max.x, x);
|
||||
bounds.max.y = Math.max(bounds.max.y, y);
|
||||
bounds.max.z = Math.max(bounds.max.z, z);
|
||||
}
|
||||
|
||||
return Number.isFinite(bounds.min.x) ? bounds : null;
|
||||
}
|
||||
|
||||
function getGlobalModelBounds(files: string[], previews: Record<string, ModelPreviewPayload>) {
|
||||
const bounds: ModelBounds = {
|
||||
min: { x: Infinity, y: Infinity, z: Infinity },
|
||||
max: { x: -Infinity, y: -Infinity, z: -Infinity },
|
||||
};
|
||||
let hasBounds = false;
|
||||
|
||||
files.forEach((fileName) => {
|
||||
const payloadBounds = previews[fileName] ? getPayloadBounds(previews[fileName]) : null;
|
||||
if (!payloadBounds) {
|
||||
return;
|
||||
}
|
||||
hasBounds = true;
|
||||
bounds.min.x = Math.min(bounds.min.x, payloadBounds.min.x);
|
||||
bounds.min.y = Math.min(bounds.min.y, payloadBounds.min.y);
|
||||
bounds.min.z = Math.min(bounds.min.z, payloadBounds.min.z);
|
||||
bounds.max.x = Math.max(bounds.max.x, payloadBounds.max.x);
|
||||
bounds.max.y = Math.max(bounds.max.y, payloadBounds.max.y);
|
||||
bounds.max.z = Math.max(bounds.max.z, payloadBounds.max.z);
|
||||
});
|
||||
|
||||
return hasBounds ? bounds : null;
|
||||
}
|
||||
|
||||
function drawDicomBaseLayer(canvas: HTMLCanvasElement, preview: DicomPreview) {
|
||||
canvas.width = preview.width;
|
||||
canvas.height = preview.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const binary = atob(preview.pixels);
|
||||
const imageData = context.createImageData(preview.width, preview.height);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
const value = binary.charCodeAt(index);
|
||||
const offset = index * 4;
|
||||
imageData.data[offset] = value;
|
||||
imageData.data[offset + 1] = value;
|
||||
imageData.data[offset + 2] = value;
|
||||
imageData.data[offset + 3] = 255;
|
||||
}
|
||||
context.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
function drawVoxelOverlayLayer(
|
||||
canvas: HTMLCanvasElement,
|
||||
preview: DicomPreview,
|
||||
files: string[],
|
||||
previews: Record<string, ModelPreviewPayload>,
|
||||
moduleStyles: Record<string, ModuleStyle>,
|
||||
slice: number,
|
||||
totalSlices: number,
|
||||
) {
|
||||
canvas.width = preview.width;
|
||||
canvas.height = preview.height;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
return { activeModules: 0, paintedTriangles: 0 };
|
||||
}
|
||||
|
||||
context.clearRect(0, 0, preview.width, preview.height);
|
||||
const globalBounds = getGlobalModelBounds(files, previews);
|
||||
if (!globalBounds) {
|
||||
return { activeModules: 0, paintedTriangles: 0 };
|
||||
}
|
||||
|
||||
const spanX = Math.max(globalBounds.max.x - globalBounds.min.x, 0.001);
|
||||
const spanY = Math.max(globalBounds.max.y - globalBounds.min.y, 0.001);
|
||||
const spanZ = Math.max(globalBounds.max.z - globalBounds.min.z, 0.001);
|
||||
const normalizedSlice = totalSlices <= 1 ? 0.5 : clamp(slice, 0, totalSlices - 1) / (totalSlices - 1);
|
||||
const targetZ = globalBounds.min.z + normalizedSlice * spanZ;
|
||||
const sliceBand = Math.max(spanZ / Math.max(totalSlices, 1) * 1.85, spanZ * 0.014, 0.001);
|
||||
const paddingX = preview.width * 0.08;
|
||||
const paddingY = preview.height * 0.08;
|
||||
const drawableWidth = Math.max(preview.width - paddingX * 2, 1);
|
||||
const drawableHeight = Math.max(preview.height - paddingY * 2, 1);
|
||||
const mapX = (x: number) => paddingX + ((x - globalBounds.min.x) / spanX) * drawableWidth;
|
||||
const mapY = (y: number) => preview.height - paddingY - ((y - globalBounds.min.y) / spanY) * drawableHeight;
|
||||
let activeModules = 0;
|
||||
let paintedTriangles = 0;
|
||||
|
||||
context.save();
|
||||
context.lineJoin = 'round';
|
||||
context.lineCap = 'round';
|
||||
context.globalCompositeOperation = 'source-over';
|
||||
|
||||
files.forEach((fileName, index) => {
|
||||
const payload = previews[fileName];
|
||||
const style = moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: moduleColors[index % moduleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
|
||||
if (!payload || style.visible === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
let modulePainted = false;
|
||||
context.fillStyle = style.color;
|
||||
context.strokeStyle = style.color;
|
||||
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
|
||||
context.lineWidth = Math.max(preview.width, preview.height) * 0.0015;
|
||||
|
||||
for (let vertexIndex = 0; vertexIndex < payload.vertices.length; vertexIndex += 9) {
|
||||
const z1 = payload.vertices[vertexIndex + 2];
|
||||
const z2 = payload.vertices[vertexIndex + 5];
|
||||
const z3 = payload.vertices[vertexIndex + 8];
|
||||
const minZ = Math.min(z1, z2, z3);
|
||||
const maxZ = Math.max(z1, z2, z3);
|
||||
|
||||
if (minZ > targetZ + sliceBand || maxZ < targetZ - sliceBand) {
|
||||
continue;
|
||||
}
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(mapX(payload.vertices[vertexIndex]), mapY(payload.vertices[vertexIndex + 1]));
|
||||
context.lineTo(mapX(payload.vertices[vertexIndex + 3]), mapY(payload.vertices[vertexIndex + 4]));
|
||||
context.lineTo(mapX(payload.vertices[vertexIndex + 6]), mapY(payload.vertices[vertexIndex + 7]));
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.72;
|
||||
context.stroke();
|
||||
context.globalAlpha = clamp(style.opacity, 0.1, 1) * 0.5;
|
||||
modulePainted = true;
|
||||
paintedTriangles += 1;
|
||||
}
|
||||
|
||||
if (modulePainted) {
|
||||
activeModules += 1;
|
||||
}
|
||||
});
|
||||
|
||||
context.restore();
|
||||
return { activeModules, paintedTriangles };
|
||||
}
|
||||
|
||||
function VoxelizationMappingView({
|
||||
project,
|
||||
moduleStyles,
|
||||
detailLimit,
|
||||
slice,
|
||||
totalSlices,
|
||||
onSliceChange,
|
||||
}: {
|
||||
project: Project | null;
|
||||
moduleStyles: Record<string, ModuleStyle>;
|
||||
detailLimit: number;
|
||||
slice: number;
|
||||
totalSlices: number;
|
||||
onSliceChange: (slice: number) => void;
|
||||
}) {
|
||||
const baseCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const [dicomPreview, setDicomPreview] = useState<DicomPreview | null>(null);
|
||||
const [modelPreviews, setModelPreviews] = useState<Record<string, ModelPreviewPayload>>({});
|
||||
const [dicomStatus, setDicomStatus] = useState('等待 DICOM 切片');
|
||||
const [overlayStatus, setOverlayStatus] = useState('等待 STL 映射');
|
||||
const [overlayStats, setOverlayStats] = useState({ activeModules: 0, paintedTriangles: 0 });
|
||||
const maxSlice = Math.max(totalSlices - 1, 0);
|
||||
const safeSlice = clamp(slice, 0, maxSlice);
|
||||
const stlFiles = project?.stlFiles ?? [];
|
||||
const visibleModuleCount = stlFiles.filter((fileName) => moduleStyles[fileName]?.visible !== false).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!project?.dicomCount) {
|
||||
setDicomPreview(null);
|
||||
setDicomStatus('没有可显示的 DICOM 切片');
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setDicomStatus('正在载入 DICOM Base Layer...');
|
||||
api.getDicomPreview(project.id, safeSlice, 'axial', 'soft')
|
||||
.then((preview) => {
|
||||
if (disposed) return;
|
||||
setDicomPreview(preview);
|
||||
setDicomStatus('DICOM Base Layer 已就绪');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (disposed) return;
|
||||
setDicomPreview(null);
|
||||
setDicomStatus(error instanceof Error ? error.message : 'DICOM 切片载入失败');
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [project?.id, project?.dicomCount, safeSlice]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || !stlFiles.length) {
|
||||
setModelPreviews({});
|
||||
setOverlayStatus('当前项目没有 STL 构件');
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
setOverlayStatus('正在载入 STL 构件层级...');
|
||||
Promise.allSettled(stlFiles.map((fileName) => (
|
||||
fetch(`/api/projects/${project.id}/models/${encodeURIComponent(fileName)}/preview?limit=${Math.max(detailLimit, 72000)}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error('STL 构件预览加载失败');
|
||||
}
|
||||
return response.json() as Promise<ModelPreviewPayload>;
|
||||
})
|
||||
.then((payload) => ({ fileName, payload }))
|
||||
))).then((results) => {
|
||||
if (disposed) return;
|
||||
const nextPreviews: Record<string, ModelPreviewPayload> = {};
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
nextPreviews[result.value.fileName] = result.value.payload;
|
||||
}
|
||||
});
|
||||
setModelPreviews(nextPreviews);
|
||||
setOverlayStatus(Object.keys(nextPreviews).length ? 'Overlay Label Map 已就绪' : 'Overlay Layer 无可用 STL 数据');
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [project?.id, stlFiles.join('|'), detailLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = baseCanvasRef.current;
|
||||
if (!canvas || !dicomPreview) {
|
||||
return;
|
||||
}
|
||||
drawDicomBaseLayer(canvas, dicomPreview);
|
||||
}, [dicomPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = overlayCanvasRef.current;
|
||||
if (!canvas || !dicomPreview) {
|
||||
return;
|
||||
}
|
||||
const stats = drawVoxelOverlayLayer(
|
||||
canvas,
|
||||
dicomPreview,
|
||||
stlFiles,
|
||||
modelPreviews,
|
||||
moduleStyles,
|
||||
safeSlice,
|
||||
Math.max(totalSlices, 1),
|
||||
);
|
||||
setOverlayStats(stats);
|
||||
}, [dicomPreview, stlFiles.join('|'), modelPreviews, JSON.stringify(moduleStyles), safeSlice, totalSlices]);
|
||||
|
||||
const stepSlice = (delta: number) => {
|
||||
onSliceChange(clamp(safeSlice + delta, 0, maxSlice));
|
||||
};
|
||||
const slicePercent = maxSlice > 0 ? (safeSlice / maxSlice) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-[420px] flex-col overflow-hidden rounded-3xl border border-slate-900 bg-slate-950 shadow-2xl">
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden bg-black">
|
||||
<div className="absolute left-4 top-4 z-10 flex flex-wrap gap-2">
|
||||
<span className="rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-slate-200">
|
||||
Base DICOM
|
||||
</span>
|
||||
<span className="rounded-lg border border-cyan-300/20 bg-cyan-950/70 px-2.5 py-1 text-[9px] font-bold uppercase tracking-widest text-cyan-100">
|
||||
Overlay Label Map
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute right-4 top-4 z-10 rounded-lg border border-white/10 bg-black/65 px-2.5 py-1 text-[10px] font-mono text-white/70">
|
||||
Z {safeSlice + 1}/{Math.max(totalSlices, 1)}
|
||||
</div>
|
||||
{dicomPreview ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<canvas ref={baseCanvasRef} className="absolute inset-0 h-full w-full object-contain" />
|
||||
<canvas ref={overlayCanvasRef} className="absolute inset-0 h-full w-full object-contain mix-blend-screen" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-8 text-center text-xs font-bold text-white/40">
|
||||
{dicomStatus}
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-4 bottom-4 z-10 rounded-2xl border border-white/10 bg-black/70 p-3 backdrop-blur-sm">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-[10px] font-bold text-white/70">
|
||||
<span className="truncate">{overlayStatus}</span>
|
||||
<span className="font-mono text-cyan-100">
|
||||
{overlayStats.activeModules}/{visibleModuleCount} 构件 · {overlayStats.paintedTriangles} 面
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid max-h-20 grid-cols-1 gap-1 overflow-auto pr-1">
|
||||
{stlFiles.map((fileName, index) => {
|
||||
const style = moduleStyles[fileName] ?? {
|
||||
visible: true,
|
||||
color: moduleColors[index % moduleColors.length],
|
||||
opacity: 0.72,
|
||||
partId: index + 1,
|
||||
};
|
||||
return (
|
||||
<div key={fileName} className={`flex items-center gap-2 text-[9px] font-bold ${style.visible ? 'text-white/70' : 'text-white/25'}`}>
|
||||
<span className="h-2.5 w-2.5 rounded-sm border border-white/20" style={{ backgroundColor: style.color, opacity: style.visible ? style.opacity : 0.25 }} />
|
||||
<span className="min-w-0 flex-1 truncate">{fileName.replace(/\.stl$/i, '')}</span>
|
||||
<span className="font-mono">ID {style.partId}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 bg-slate-950 px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">Slice Navigator</p>
|
||||
<span className="font-mono text-[10px] font-bold text-cyan-100">
|
||||
{safeSlice + 1} / {Math.max(totalSlices, 1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-[28px_1fr_28px] items-center gap-3">
|
||||
<button
|
||||
onClick={() => stepSlice(-1)}
|
||||
disabled={safeSlice <= 0}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
|
||||
title="上一层"
|
||||
>
|
||||
<ChevronLeft size={15} />
|
||||
</button>
|
||||
<div className="relative h-8">
|
||||
<div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-800" />
|
||||
<div
|
||||
className="absolute top-1/2 h-2 -translate-y-1/2 rounded-full bg-cyan-400"
|
||||
style={{ left: 0, width: `${slicePercent}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max={maxSlice}
|
||||
value={safeSlice}
|
||||
onChange={(event) => onSliceChange(Number(event.target.value))}
|
||||
className="mapping-slice-input"
|
||||
aria-label="逆向分割映射视图切片导航"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => stepSlice(1)}
|
||||
disabled={safeSlice >= maxSlice}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg border border-slate-700 bg-slate-900 text-slate-200 hover:border-cyan-400 hover:text-cyan-100 disabled:opacity-35"
|
||||
title="下一层"
|
||||
>
|
||||
<ChevronRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const [sliceStart, setSliceStart] = useState(0);
|
||||
const [sliceEnd, setSliceEnd] = useState(49);
|
||||
const [mappingSlice, setMappingSlice] = useState(0);
|
||||
const [modelPose, setModelPose] = useState<ModelPose>(defaultModelPose);
|
||||
const [displayLevel, setDisplayLevel] = useState<DisplayLevel>('standard');
|
||||
const [dicomOpacityLevel, setDicomOpacityLevel] = useState<DicomOpacityLevel>('low');
|
||||
@@ -879,6 +1270,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const maxIndex = Math.max((item.dicomCount || 1) - 1, 0);
|
||||
setSliceStart(0);
|
||||
setSliceEnd(maxIndex);
|
||||
setMappingSlice(maxIndex);
|
||||
setModelPose(defaultModelPose);
|
||||
const nextStyles: Record<string, ModuleStyle> = {};
|
||||
(item.stlFiles ?? []).forEach((fileName, index) => {
|
||||
@@ -1034,6 +1426,7 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
const maxSlice = Math.max((project?.dicomCount ?? 1) - 1, 0);
|
||||
const safeSliceStart = clamp(sliceStart, 0, maxSlice);
|
||||
const safeSliceEnd = clamp(sliceEnd, 0, maxSlice);
|
||||
const safeMappingSlice = clamp(mappingSlice, 0, maxSlice);
|
||||
const displayStart = Math.min(safeSliceStart, safeSliceEnd);
|
||||
const displayEnd = Math.max(safeSliceStart, safeSliceEnd);
|
||||
const rangeStartPercent = maxSlice > 0 ? (displayStart / maxSlice) * 100 : 0;
|
||||
@@ -1416,8 +1809,8 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
<div className="lg:col-span-3 flex flex-col gap-4 overflow-hidden">
|
||||
<div className="px-2 flex items-center justify-between shrink-0">
|
||||
<h3 className="font-bold text-slate-700 flex items-center gap-2">
|
||||
<Play size={18} className="text-blue-500" />
|
||||
Mask 展示
|
||||
<Layers size={18} className="text-cyan-500" />
|
||||
逆向分割映射视图
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -1439,15 +1832,13 @@ export default function ReverseWorkspace({ projectId }: { projectId: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CutSectionPreview
|
||||
<VoxelizationMappingView
|
||||
project={project}
|
||||
volume={fusionVolume}
|
||||
modelPose={modelPose}
|
||||
moduleStyles={moduleStyles}
|
||||
detailLimit={selectedDisplay.limit}
|
||||
cutEnabled={cutEnabled}
|
||||
cutStart={displayStart}
|
||||
cutEnd={displayEnd}
|
||||
slice={safeMappingSlice}
|
||||
totalSlices={project?.dicomCount ?? 0}
|
||||
onSliceChange={setMappingSlice}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,3 +59,60 @@
|
||||
.dicom-range-input:active::-moz-range-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: transparent;
|
||||
height: 100%;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-slice-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-webkit-slider-runnable-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 22px;
|
||||
margin-top: -7px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-moz-range-track {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.mapping-slice-input::-moz-range-thumb {
|
||||
background: #22d3ee;
|
||||
border: 3px solid #0f172a;
|
||||
border-radius: 9999px;
|
||||
box-shadow: 0 0 0 4px rgba(34, 211, 238, 0.16), 0 8px 18px rgba(8, 47, 73, 0.45);
|
||||
cursor: grab;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.mapping-slice-input:active::-webkit-slider-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mapping-slice-input:active::-moz-range-thumb {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user