Merge branch 'main' of ssh://ramanujan.glitch.university:2222/glitch-university/gupi-osint-board
This commit is contained in:
+18
-6
@@ -3,7 +3,7 @@ import { BookOpen, Building2, CalendarClock, Camera, Check, ChevronRight, Circle
|
|||||||
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
import type { BriefConcept, CaseDocument, CaseReport, CaseReportSubmissionInput, CaseState, Connection, DocumentCaptureKind, DocumentSemanticAnalysis, EventExhibit, Evidence, EvidenceMatchRuleDefinition, Exhibit, ExhibitRelation, FolderExhibit, LevelBrief, LevelFlag, LevelGoal, NotePresentation, OrganizationKind, PartyExhibit, PartyKind, SourceFileType, TimelineRange, TimelineView, UploadedCaseDocument } from './types'
|
||||||
import { AdminPanel } from './admin'
|
import { AdminPanel } from './admin'
|
||||||
import { audio } from './audio'
|
import { audio } from './audio'
|
||||||
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
import { clampBoardZoom, containedIds, dateValue, discardExhibit, folderIsOpen, moveBoardPoint, nextOpenBoardPosition, nextVisibleBoardPosition, normalizeCase, panViewport, projectThreadTag, threadCurve, threadTagLateralLimit, threadTagPlacement, timelinePositionPercent, timelineRange, zoomFromPinch, zoomFromWheel, zoomViewportAt } from './boardDomain'
|
||||||
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
import { defaultConnectionLabel, documentCapture, documentCaptureRegistry, documentExhibits, documentWidget, evidenceExhibits, exhibitWidget, mugshotIdentification, type ExhibitWidgetContext, type WidgetCommand } from './exhibitRegistry'
|
||||||
import type { PlaythroughState } from './narrative'
|
import type { PlaythroughState } from './narrative'
|
||||||
|
|
||||||
@@ -381,13 +381,23 @@ export function App() {
|
|||||||
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
const uploadFiles = useCallback(async (files: FileList | File[], source: 'file' | 'clipboard' = 'file') => {
|
||||||
if (!caseState) return
|
if (!caseState) return
|
||||||
const queue = Array.from(files)
|
const queue = Array.from(files)
|
||||||
|
const boardScreen = boardRef.current?.getBoundingClientRect()
|
||||||
|
const screen = { width: boardScreen?.width || window.innerWidth, height: boardScreen?.height || window.innerHeight }
|
||||||
|
const portraitMobile = screen.width <= 900 && screen.height > screen.width
|
||||||
|
const insets = {
|
||||||
|
top: Math.min(140, screen.height * .28),
|
||||||
|
right: portraitMobile ? 68 : 18,
|
||||||
|
bottom: portraitMobile ? 18 : 68,
|
||||||
|
left: 18,
|
||||||
|
}
|
||||||
|
const reserved = [...caseState.exhibits]
|
||||||
setUploading(queue.length)
|
setUploading(queue.length)
|
||||||
setDraggingFiles(false)
|
setDraggingFiles(false)
|
||||||
for (const [queueIndex, file] of queue.entries()) {
|
for (const file of queue) {
|
||||||
const position = nextOpenBoardPosition(caseState.exhibits, {
|
// Reserve for the largest image presentation so choosing Clip, Image, or
|
||||||
x: Math.max(100, (520 - caseState.viewport.x) / caseState.viewport.zoom) + queueIndex * 24,
|
// Mugshot in the following dialog cannot make it grow beyond the viewport.
|
||||||
y: Math.max(100, (310 - caseState.viewport.y) / caseState.viewport.zoom) + queueIndex * 24,
|
const reservedSize = { width: 244, height: 294 }
|
||||||
}, { width: 174, height: 145 })
|
const position = nextVisibleBoardPosition(reserved, caseState.viewport, screen, reservedSize, insets, { width: BOARD_W, height: BOARD_H })
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
form.append('x', String(position.x))
|
form.append('x', String(position.x))
|
||||||
@@ -398,6 +408,7 @@ export function App() {
|
|||||||
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || `Upload failed (${response.status})`) }
|
||||||
const uploaded: UploadedCaseDocument = await response.json()
|
const uploaded: UploadedCaseDocument = await response.json()
|
||||||
const { analysis, ...document } = uploaded
|
const { analysis, ...document } = uploaded
|
||||||
|
reserved.push({ ...document, ...position, ...reservedSize })
|
||||||
update(s => {
|
update(s => {
|
||||||
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
return { ...s, exhibits: [...s.exhibits, { ...document, ...position }] }
|
||||||
})
|
})
|
||||||
@@ -974,6 +985,7 @@ function Board({ state, selected, locatorDocumentId, linkFrom, recentlyCreatedEx
|
|||||||
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left,top,width:document.width,height:document.height,rotate:`${document.rotation}deg`,zIndex:document.zIndex,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
{documentExhibits(state.exhibits).filter(document => !document.hidden).map(document => { const membership = containmentRelations.find(relation => relation.toExhibitId === document.id); const folder = membership ? byId.get(membership.fromExhibitId) : undefined; const open = folder?.type !== 'folder' || folder.isOpen; const located = open && locatorDocumentId === document.id; const left = open ? document.x : folder.x + folder.width / 2 - document.width / 2, top = open ? document.y : folder.y + 45; const definition = documentWidget(document.fileType); const presentation=documentCapture(document.captureKind); const identification=mugshotIdentification(document,state.exhibits,state.connections); const Preview = definition.Preview; const source = document.assetId ? `/api/assets/${encodeURIComponent(document.assetId)}` : ''; const missingProvenance=[!document.publishedAt ? 'DATE' : '',!document.sourceCitation?.trim() ? 'SOURCE' : ''].filter(Boolean); return <article key={document.id} data-document-locator-target={located ? document.id : undefined} data-temporal-id={`widget:${document.id}`} data-capture-kind={document.captureKind} data-identified-party-id={identification?.party.id} className={`source-file-widget ${open ? 'open' : 'closed'} file-type-${document.fileType} capture-kind-${document.captureKind} ${identification ? 'mugshot-identified' : ''} ${selected === document.id ? 'selected' : ''} ${located ? 'document-located' : ''} ${linkFrom === document.id ? 'linking' : ''} ${linkFrom && linkFrom !== document.id ? 'thread-target' : ''} ${arrivingExhibitIds.includes(document.id) ? 'arriving' : ''}`} style={{ left,top,width:document.width,height:document.height,rotate:`${document.rotation}deg`,zIndex:document.zIndex,'--clip-inset-rotation':`${clippingInsetRotation(document.id)}deg` } as React.CSSProperties}
|
||||||
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
onPointerDown={event => { event.stopPropagation(); if (linkFrom && event.button === 0) return; if (tool === 'hand' || event.button === 1) { event.preventDefault(); pointerDown(event) } else if (open && event.button === 0) pointerDown(event, { kind: 'widget', id: document.id }) }}
|
||||||
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
onPointerMove={event => { event.stopPropagation(); pointerMove(event) }} onPointerUp={event => { event.stopPropagation(); finishDrag(event) }} onPointerCancel={event => { event.stopPropagation(); finishDrag(event) }} onClick={event => { event.stopPropagation(); if (suppressClick.current) { suppressClick.current = false; return } if (!open) return; if (tool === 'move') onCardClick(document.id) }} onDoubleClick={() => open && !linkFrom && onOpenSource(document.id)}>
|
||||||
|
{document.captureKind === 'clipping' && <span className="clip-stamped-pin" aria-hidden="true"/>}
|
||||||
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
<header><span>{(document.captureKind === 'unclassified' ? definition.label : presentation.label).toUpperCase()}</span><i>{String(document.displayNumber || (membership?.sortOrder || 0) + 1).padStart(2, '0')}</i></header>
|
||||||
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
<div className="source-file-preview"><Preview document={document} source={source} onMemoryCue={cue => onUpdateDocumentCue(document.id, cue)}/></div>
|
||||||
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
{document.captureKind === 'photo' ? <MugshotCaption name={identification?.party.title || ''}/> : document.captureKind !== 'clipping' ? <strong>{document.title}</strong> : null}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
folderIsOpen,
|
folderIsOpen,
|
||||||
moveBoardPoint,
|
moveBoardPoint,
|
||||||
nextOpenBoardPosition,
|
nextOpenBoardPosition,
|
||||||
|
nextVisibleBoardPosition,
|
||||||
normalizeCase,
|
normalizeCase,
|
||||||
panViewport,
|
panViewport,
|
||||||
relationPosition,
|
relationPosition,
|
||||||
@@ -121,6 +122,32 @@ describe('board coordinate math', () => {
|
|||||||
{ x: 826, y: 400, width: 280, height: 160 },
|
{ x: 826, y: 400, width: 280, height: 160 },
|
||||||
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
], preferred, { width: 280 })).toEqual({ x: 174, y: 400 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('places new evidence within a panned and zoomed mobile viewport', () => {
|
||||||
|
const viewport = { x: -720, y: -310, zoom: .75 }
|
||||||
|
const screen = { width: 390, height: 658 }
|
||||||
|
const size = { width: 244, height: 294 }
|
||||||
|
const position = nextVisibleBoardPosition([], viewport, screen, size, { top: 120, right: 68, bottom: 18, left: 16 })
|
||||||
|
const screenLeft = viewport.x + position.x * viewport.zoom
|
||||||
|
const screenTop = viewport.y + position.y * viewport.zoom
|
||||||
|
expect(screenLeft).toBeGreaterThanOrEqual(16)
|
||||||
|
expect(screenTop).toBeGreaterThanOrEqual(120)
|
||||||
|
expect(screenLeft + size.width * viewport.zoom).toBeLessThanOrEqual(screen.width - 68)
|
||||||
|
expect(screenTop + size.height * viewport.zoom).toBeLessThanOrEqual(screen.height - 18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps collision avoidance inside the currently visible board area', () => {
|
||||||
|
const viewport = { x: -400, y: -200, zoom: .5 }
|
||||||
|
const screen = { width: 800, height: 600 }
|
||||||
|
const size = { width: 174, height: 145 }
|
||||||
|
const first = nextVisibleBoardPosition([], viewport, screen, size)
|
||||||
|
const second = nextVisibleBoardPosition([{ ...first, ...size }], viewport, screen, size)
|
||||||
|
expect(second).not.toEqual(first)
|
||||||
|
expect(viewport.x + second.x * viewport.zoom).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(viewport.y + second.y * viewport.zoom).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(viewport.x + (second.x + size.width) * viewport.zoom).toBeLessThanOrEqual(screen.width)
|
||||||
|
expect(viewport.y + (second.y + size.height) * viewport.zoom).toBeLessThanOrEqual(screen.height)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('timeline projection', () => {
|
describe('timeline projection', () => {
|
||||||
|
|||||||
@@ -123,6 +123,71 @@ export function nextOpenBoardPosition(
|
|||||||
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
|
return { x: Math.max(80, Math.min(bounds.width - size.width - 80, preferred.x)), y: Math.min(bounds.height - height - 80, preferred.y + evidence.length * 24) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BoardScreenSize { width: number; height: number }
|
||||||
|
export interface BoardScreenInsets { top?: number; right?: number; bottom?: number; left?: number }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds an open board position inside the portion of the canvas the player can
|
||||||
|
* currently see. Screen insets reserve space for board chrome such as the mobile
|
||||||
|
* tool rail. If the visible area is too small to contain the whole exhibit, the
|
||||||
|
* exhibit is centred so the largest useful portion remains on screen.
|
||||||
|
*/
|
||||||
|
export function nextVisibleBoardPosition(
|
||||||
|
exhibits: Pick<Exhibit, 'x' | 'y' | 'width' | 'height'>[],
|
||||||
|
viewport: Viewport,
|
||||||
|
screen: BoardScreenSize,
|
||||||
|
size: { width: number; height: number },
|
||||||
|
insets: BoardScreenInsets = {},
|
||||||
|
boardBounds = { width: 2400, height: 1500 },
|
||||||
|
) {
|
||||||
|
if (!Number.isFinite(viewport.zoom) || viewport.zoom <= 0) throw new RangeError('Board zoom must be positive')
|
||||||
|
const left = Math.max(0, insets.left || 0)
|
||||||
|
const top = Math.max(0, insets.top || 0)
|
||||||
|
const right = Math.max(0, insets.right || 0)
|
||||||
|
const bottom = Math.max(0, insets.bottom || 0)
|
||||||
|
const usableWidth = Math.max(1, screen.width - left - right)
|
||||||
|
const usableHeight = Math.max(1, screen.height - top - bottom)
|
||||||
|
const centre = {
|
||||||
|
x: (left + usableWidth / 2 - viewport.x) / viewport.zoom - size.width / 2,
|
||||||
|
y: (top + usableHeight / 2 - viewport.y) / viewport.zoom - size.height / 2,
|
||||||
|
}
|
||||||
|
const worldMinX = 40
|
||||||
|
const worldMinY = 40
|
||||||
|
const worldMaxX = Math.max(worldMinX, boardBounds.width - size.width - 40)
|
||||||
|
const worldMaxY = Math.max(worldMinY, boardBounds.height - size.height - 40)
|
||||||
|
const visibleMinX = Math.max(worldMinX, (left - viewport.x) / viewport.zoom)
|
||||||
|
const visibleMinY = Math.max(worldMinY, (top - viewport.y) / viewport.zoom)
|
||||||
|
const visibleMaxX = Math.min(worldMaxX, (screen.width - right - viewport.x) / viewport.zoom - size.width)
|
||||||
|
const visibleMaxY = Math.min(worldMaxY, (screen.height - bottom - viewport.y) / viewport.zoom - size.height)
|
||||||
|
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value))
|
||||||
|
|
||||||
|
// A whole exhibit may not fit at the current zoom on a very short landscape
|
||||||
|
// phone. In that case centring it gives the player the largest visible area.
|
||||||
|
const preferred = {
|
||||||
|
x: clamp(centre.x, worldMinX, worldMaxX),
|
||||||
|
y: clamp(centre.y, worldMinY, worldMaxY),
|
||||||
|
}
|
||||||
|
if (visibleMinX > visibleMaxX || visibleMinY > visibleMaxY) return preferred
|
||||||
|
preferred.x = clamp(preferred.x, visibleMinX, visibleMaxX)
|
||||||
|
preferred.y = clamp(preferred.y, visibleMinY, visibleMaxY)
|
||||||
|
|
||||||
|
const gap = 28
|
||||||
|
const overlaps = (x: number, y: number) => exhibits.some(item =>
|
||||||
|
x < item.x + item.width + gap && x + size.width + gap > item.x &&
|
||||||
|
y < item.y + item.height + gap && y + size.height + gap > item.y)
|
||||||
|
const axisCandidates = (origin: number, minimum: number, maximum: number, step: number) => {
|
||||||
|
const values = [origin, minimum, maximum]
|
||||||
|
const rings = Math.ceil((maximum - minimum) / step) + 1
|
||||||
|
for (let ring = 1; ring <= rings; ring += 1) values.push(clamp(origin + ring * step, minimum, maximum), clamp(origin - ring * step, minimum, maximum))
|
||||||
|
return [...new Set(values.map(value => Math.round(value * 1000) / 1000))]
|
||||||
|
}
|
||||||
|
const xs = axisCandidates(preferred.x, visibleMinX, visibleMaxX, size.width + 46)
|
||||||
|
const ys = axisCandidates(preferred.y, visibleMinY, visibleMaxY, size.height + 46)
|
||||||
|
const candidates = xs.flatMap(x => ys.map(y => ({ x, y }))).sort((a, b) =>
|
||||||
|
Math.hypot(a.x - preferred.x, a.y - preferred.y) - Math.hypot(b.x - preferred.x, b.y - preferred.y))
|
||||||
|
return candidates.find(candidate => !overlaps(candidate.x, candidate.y)) || preferred
|
||||||
|
}
|
||||||
|
|
||||||
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
export function panViewport(origin: Viewport, screenDelta: { x: number; y: number }): Viewport {
|
||||||
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
|
return { ...origin, x: origin.x + screenDelta.x, y: origin.y + screenDelta.y }
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -221,7 +221,8 @@ button:focus-visible { outline: 2px solid #e99a44; outline-offset: 2px; }
|
|||||||
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
.source-file-widget.capture-kind-scene .source-file-preview img { object-fit: cover; filter: saturate(.86) contrast(1.05); }
|
||||||
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
.source-file-widget.capture-kind-scene > strong { font-size: 11px; }
|
||||||
.source-file-widget.capture-kind-clipping { padding:14px 13px 10px;overflow:visible;background:linear-gradient(105deg,#e2dfcf,#d3d1c2);border:2px solid #b99a68;box-shadow:8px 10px 0 #020b0980,0 0 0 2px #526059; }
|
.source-file-widget.capture-kind-clipping { padding:14px 13px 10px;overflow:visible;background:linear-gradient(105deg,#e2dfcf,#d3d1c2);border:2px solid #b99a68;box-shadow:8px 10px 0 #020b0980,0 0 0 2px #526059; }
|
||||||
.source-file-widget.capture-kind-clipping::after { content:'';position:absolute;z-index:5;top:-7px;left:50%;width:13px;height:13px;translate:-50% 0;border-radius:50%;background:#9e392f;border:2px solid #5d1e1b;box-shadow:0 2px 2px #0006; }
|
.clip-stamped-pin { position:absolute;z-index:6;top:-7px;left:50%;width:24px;height:24px;translate:-50% 0;rotate:187deg;border:1px solid #5d5e59;border-radius:50%;background:conic-gradient(from 205deg,#73756f,#e5e4da 17%,#8c8e87 34%,#f5f2e5 49%,#74766f 67%,#c5c5bc 84%,#73756f);box-shadow:0 -3px 3px #0008,inset 1px 1px 1px #fff9,inset -2px -2px 2px #36393466;pointer-events:none; }
|
||||||
|
.clip-stamped-pin::before { content:'';position:absolute;left:7px;top:5px;width:10px;height:12px;background:#d9d6c7;clip-path:polygon(50% 100%,0 0,100% 0);filter:drop-shadow(0 1px 1px #5b584d88); }
|
||||||
.source-file-widget.capture-kind-clipping.open { transform:scale(1); }
|
.source-file-widget.capture-kind-clipping.open { transform:scale(1); }
|
||||||
.source-file-widget.capture-kind-clipping .source-file-preview { box-sizing:border-box;width:100%;height:210px;margin:3px 0 8px;border:2px solid #f4efe1;background:#c8c2b3;box-shadow:2px 3px 3px #10151170;transform:rotate(var(--clip-inset-rotation,.7deg));transform-origin:50% 48%; }
|
.source-file-widget.capture-kind-clipping .source-file-preview { box-sizing:border-box;width:100%;height:210px;margin:3px 0 8px;border:2px solid #f4efe1;background:#c8c2b3;box-shadow:2px 3px 3px #10151170;transform:rotate(var(--clip-inset-rotation,.7deg));transform-origin:50% 48%; }
|
||||||
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit:contain;filter:grayscale(.12) contrast(1.08); }
|
.source-file-widget.capture-kind-clipping .source-file-preview img { object-fit:contain;filter:grayscale(.12) contrast(1.08); }
|
||||||
|
|||||||
Reference in New Issue
Block a user