初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1 @@
export const DOMINO_EL_PREFIX = 'domino-el-'

View File

@@ -0,0 +1,10 @@
import { DOMINO_EL_PREFIX } from './constants'
/**
* 获取 Domino 元素的 DOM 节点
*/
export function getDominoDOM(id: string): HTMLElement | null {
if (typeof document === 'undefined' || !id) return null
const finalId = id.startsWith(DOMINO_EL_PREFIX) ? id : DOMINO_EL_PREFIX + id
return document.getElementById(finalId)
}

View File

@@ -0,0 +1,236 @@
import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react'
import { useDominoAnchor, type AnchorRect } from './use-domino-anchor'
export type Placement =
| 'top'
| 'top-start'
| 'top-end'
| 'bottom'
| 'bottom-start'
| 'bottom-end'
| 'left'
| 'left-start'
| 'left-end'
| 'right'
| 'right-start'
| 'right-end'
export interface DominoAnchorProps {
/** The ID of the element on the canvas to anchor to */
id: string | null
/** The container ref where the canvas is rendered */
containerRef: React.RefObject<HTMLElement | null>
/** Alignment preference */
placement?: Placement
/** Margin from the anchor element */
offset?: number
/** Whether to use absolute screen space coordinates (for body portals) */
screenSpace?: boolean
// --- Props for Simple/Advanced Usage ---
className?: string
style?: React.CSSProperties
/**
* Children can be either a ReactNode (simple usage) or a render function (advanced control).
* If a function is provided, it receives calculated style, anchor, and measureRef.
*/
children?:
| React.ReactNode
| ((props: {
style: React.CSSProperties
anchor: AnchorRect
measureRef: (node: HTMLElement | null) => void
}) => React.ReactNode)
}
export const DominoAnchor: React.FC<DominoAnchorProps> = ({
id,
containerRef,
placement = 'right-start',
offset = 12,
screenSpace = false,
className,
style,
children,
}) => {
const anchor = useDominoAnchor(id, containerRef, screenSpace)
const [size, setSize] = useState({ width: 0, height: 0 })
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const measureRef = useCallback((node: HTMLElement | null) => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect()
}
if (node) {
// Immediate measurement
const rect = node.getBoundingClientRect()
setSize(prev => {
if (prev.width === rect.width && prev.height === rect.height)
return prev
return { width: rect.width, height: rect.height }
})
// Setup observer for dynamic changes
if (!resizeObserverRef.current) {
resizeObserverRef.current = new ResizeObserver(entries => {
const entry = entries[0]
if (entry) {
const { width, height } = entry.contentRect
setSize(prev => {
if (prev.width === width && prev.height === height) return prev
return { width, height }
})
}
})
}
resizeObserverRef.current.observe(node)
}
}, [])
useEffect(() => {
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect()
}
}
}, [])
const finalStyle = useMemo((): React.CSSProperties | null => {
if (!anchor) return null
let left = 0
let top = 0
const { width: floatingWidth, height: floatingHeight } = size
// Initial placement logic (Standard 12 positions)
switch (placement) {
// TOP
case 'top':
left = anchor.centerX - floatingWidth / 2
top = anchor.top - floatingHeight - offset
break
case 'top-start':
left = anchor.left
top = anchor.top - floatingHeight - offset
break
case 'top-end':
left = anchor.right - floatingWidth
top = anchor.top - floatingHeight - offset
break
// BOTTOM
case 'bottom':
left = anchor.centerX - floatingWidth / 2
top = anchor.bottom + offset
break
case 'bottom-start':
left = anchor.left
top = anchor.bottom + offset
break
case 'bottom-end':
left = anchor.right - floatingWidth
top = anchor.bottom + offset
break
// LEFT
case 'left':
left = anchor.left - floatingWidth - offset
top = anchor.centerY - floatingHeight / 2
break
case 'left-start':
left = anchor.left - floatingWidth - offset
top = anchor.top
break
case 'left-end':
left = anchor.left - floatingWidth - offset
top = anchor.bottom - floatingHeight
break
// RIGHT
case 'right':
left = anchor.right + offset
top = anchor.centerY - floatingHeight / 2
break
case 'right-start':
left = anchor.right + offset
top = anchor.top
break
case 'right-end':
left = anchor.right + offset
top = anchor.bottom - floatingHeight
break
default:
left = anchor.right + offset
top = anchor.top
}
// --- Automatic Boundary Avoidance (Collision Detection) ---
const viewportWidth =
screenSpace || !containerRef.current
? window.innerWidth
: containerRef.current.clientWidth
const viewportHeight =
screenSpace || !containerRef.current
? window.innerHeight
: containerRef.current.clientHeight
const containerRect = containerRef.current?.getBoundingClientRect()
// Define screen-space or relative boundaries
const minX = screenSpace && containerRect ? containerRect.left : 0
const maxX =
screenSpace && containerRect ? containerRect.right : viewportWidth
const minY = screenSpace && containerRect ? containerRect.top : 0
const maxY =
screenSpace && containerRect ? containerRect.bottom : viewportHeight
// 3. Clamping & Sticking Logic (Horizontal)
// We treat the "near" edge of the popup (the one relative to the anchor) as the sticky point
if (placement.includes('left')) {
// "当弹窗在元素左侧出现时,弹窗的 right side edge 与窗口的 left side edge 相吸附"
const currentRight = left + floatingWidth
const clampedRight = Math.max(minX, Math.min(currentRight, maxX))
left = clampedRight - floatingWidth
} else if (placement.includes('right')) {
left = Math.max(minX, Math.min(left, maxX))
}
// Vertical stay-within-bounds (Keep it in view vertically)
if (top + floatingHeight > maxY) {
top = maxY - floatingHeight
}
if (top < minY) {
top = minY
}
return {
position: screenSpace ? 'fixed' : ('absolute' as const),
left,
top,
zIndex: 1000,
pointerEvents: 'auto' as const,
visibility: (size.width === 0
? 'hidden'
: 'visible') as React.CSSProperties['visibility'],
}
}, [anchor, placement, offset, size, containerRef, screenSpace])
if (!anchor || !finalStyle) return null
// Advanced Usage: children is a render function
if (typeof children === 'function') {
return <>{children({ style: finalStyle, anchor, measureRef })}</>
}
// Simple Usage: wrap children in a div with position styles
return (
<div
ref={measureRef}
style={{ ...finalStyle, ...style }}
className={className}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,280 @@
import React, {
memo,
useEffect,
useLayoutEffect,
forwardRef,
useImperativeHandle,
} from 'react'
import { cn } from '@/utils/cn'
import { useInteractions } from './use-interactions'
import { useGestures } from './use-gestures'
import {
useDominoStore,
useDominoStoreInstance,
useDominoDOMContext,
DominoRenderHooksContext,
} from './domino-hooks'
import { ElementRenderer } from './element-renderer'
import { SelectionOverlay } from './selection-overlay'
import { useDominoScrollIntoView } from './use-domino-scroll-into-view'
import {
type Viewport,
type DominoCanvasProps,
type ScrollIntoViewOptions,
} from './domino'
export interface DominoCanvasRef {
scrollIntoView: (elementId: string, options?: ScrollIntoViewOptions) => void
}
/**
* 拖拽框选框渲染器:独立订阅避免主画布重绘
*/
const MarqueeOverlay = memo(() => {
const selectionBox = useDominoStore(state => state.selectionBox)
if (!selectionBox) return null
return (
<div
className='domino-selection-box absolute pointer-events-none z-[1000]'
style={{
left: selectionBox.x,
top: selectionBox.y,
width: selectionBox.width,
height: selectionBox.height,
borderWidth: 'calc(1px / var(--domino-scale))',
borderStyle: 'solid',
borderColor: 'color-mix(in srgb, var(--editor-accent) 55%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--editor-accent) 8%, transparent)',
}}
/>
)
})
MarqueeOverlay.displayName = 'MarqueeOverlay'
export const DominoCanvas = memo(
forwardRef<DominoCanvasRef, DominoCanvasProps>(
function DominoCanvas(props, ref) {
const {
className,
style,
minScale = 0.1,
maxScale = 20,
padding,
readOnly = false,
onClick,
onSelect,
onElementClick,
onTransformEnd,
onViewportChange,
onContextMenu,
onHoverChange,
onElementsRemove,
elements: externalElements,
elementOrder: externalElementOrder,
renderElement,
renderElementMetadata,
} = props
const store = useDominoStoreInstance()
const elementOrder = useDominoStore(state => state.elementOrder)
const setViewport = useDominoStore(state => state.setViewport)
const setPadding = useDominoStore(state => state.setPadding)
const setReadOnly = useDominoStore(state => state.setReadOnly)
const setElementsData = useDominoStore(state => state.setElementsData)
const hostRef = useDominoDOMContext()
const localRef = React.useRef<HTMLDivElement>(null)
const containerRef = (hostRef ||
localRef) as React.RefObject<HTMLDivElement>
const worldRef = React.useRef<HTMLDivElement>(null)
const scrollIntoView = useDominoScrollIntoView(containerRef)
useImperativeHandle(ref, () => ({
scrollIntoView,
}))
// Sync controlled data
useLayoutEffect(() => {
if (externalElements && externalElementOrder) {
setElementsData(externalElements, externalElementOrder)
}
}, [externalElements, externalElementOrder, setElementsData])
useLayoutEffect(() => {
if (padding) {
setPadding(padding)
}
}, [padding, setPadding])
useLayoutEffect(() => {
setReadOnly(readOnly)
}, [readOnly, setReadOnly])
useEffect(() => {
if (props.initViewport !== undefined) {
setViewport(props.initViewport)
}
}, [])
// Fast-path for viewport updates: bypass React renders
useEffect(() => {
const updateTransform = (v: Viewport) => {
if (worldRef.current) {
const x = Math.round(v.x * 100) / 100
const y = Math.round(v.y * 100) / 100
const s = Math.round(v.scale * 10000) / 10000
worldRef.current.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${s})`
}
containerRef.current?.style.setProperty(
'--domino-scale',
(Math.round(v.scale * 10000) / 10000).toString(),
)
}
// Set initial state
updateTransform(store.getState().viewport)
// Subscribe to changes
let lastViewport = store.getState().viewport
const unsubscribe = store.subscribe(state => {
const v = state.viewport
if (v === lastViewport) return
lastViewport = v
updateTransform(v)
onViewportChange?.(v)
})
return unsubscribe
}, [store, onViewportChange])
useEffect(() => {
let lastSelectedIds = store.getState().selectedIds
return store.subscribe(state => {
const ids = state.selectedIds
if (ids === lastSelectedIds) return
lastSelectedIds = ids
onSelect?.(ids)
})
}, [store, onSelect])
// Subscribe to removals
useEffect(() => {
let lastOrder = store.getState().elementOrder
return store.subscribe(state => {
const newOrder = state.elementOrder
if (newOrder === lastOrder) return
const removedIds = lastOrder.filter(id => !newOrder.includes(id))
lastOrder = newOrder
if (removedIds.length > 0) {
onElementsRemove?.(removedIds)
}
})
}, [store, onElementsRemove])
// Subscribe to hover changes
useEffect(() => {
let lastHoveredId = store.getState().hoveredElementId
return store.subscribe(state => {
const id = state.hoveredElementId
if (id === lastHoveredId) return
lastHoveredId = id
onHoverChange?.(id)
})
}, [store, onHoverChange])
// Force zero scroll to prevent coordinate misalignment
useEffect(() => {
const container = containerRef.current
if (!container) return
const resetScroll = () => {
if (container.scrollTop !== 0) container.scrollTop = 0
if (container.scrollLeft !== 0) container.scrollLeft = 0
}
container.addEventListener('scroll', resetScroll)
// Initial reset
resetScroll()
return () => {
container.removeEventListener('scroll', resetScroll)
}
}, [])
useGestures(containerRef, { minScale, maxScale })
const interactionOptions = React.useMemo(
() => ({
minScale,
maxScale,
onTransformEnd,
onClick,
onElementClick,
}),
[minScale, maxScale, onTransformEnd, onClick, onElementClick],
)
const {
onDoubleClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
} = useInteractions(containerRef, interactionOptions)
const renderHooksValue = React.useMemo(
() => ({
renderElement,
renderElementMetadata,
}),
[renderElement, renderElementMetadata],
)
const mode = useDominoStore(state => state.mode)
return (
<DominoRenderHooksContext.Provider value={renderHooksValue}>
<div
ref={containerRef}
className={cn(
'domino-canvas-root domino-canvas relative w-full h-full overflow-hidden bg-muted outline-none select-none touch-none',
`${mode}-mode`,
className,
)}
style={style}
tabIndex={-1}
onContextMenu={e => {
e.preventDefault()
const target = e.target as HTMLElement
const elementEl = target.closest('.domino-element')
onContextMenu?.(e, elementEl?.id)
}}
onPointerDown={onPointerDown}
onDoubleClick={onDoubleClickCapture}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
>
<div
ref={worldRef}
className='absolute origin-top-left pointer-events-none'
>
{elementOrder.map(id => (
<ElementRenderer key={id} id={id} />
))}
<SelectionOverlay />
<MarqueeOverlay />
</div>
{/* Overlay UI Layer - Separated to prevent event bubbling to interaction layer */}
<div className='absolute inset-0 pointer-events-none overflow-hidden'>
{props.children}
</div>
</div>
</DominoRenderHooksContext.Provider>
)
},
),
)

View File

@@ -0,0 +1,112 @@
import { createContext, useContext, useMemo, type RefObject } from 'react'
import { useStoreWithEqualityFn } from 'zustand/traditional'
import type { DominoStore, DominoStoreState } from './domino-store'
import { getElementsBounds } from './math'
import type { DominoCanvasProps } from './domino'
// --- 1. Context Definitions ---
/**
* Store Context: 核心状态管理器
*/
export const DominoContext = createContext<DominoStore | null>(null)
/**
* DOM 引用上下文:提供容器节点的稳定引用
*/
export const DominoDOMContext =
createContext<RefObject<HTMLElement | null> | null>(null)
/**
* 渲染钩子上下文:提供业务自定义渲染函数
*/
export const DominoRenderHooksContext = createContext<{
renderElement?: DominoCanvasProps['renderElement']
renderElementMetadata?: DominoCanvasProps['renderElementMetadata']
}>({})
// --- 2. Store Consumption Hooks ---
export function useDominoStore<T>(
selector: (state: DominoStoreState) => T,
equalityFn?: (a: T, b: T) => boolean,
): T {
const store = useContext(DominoContext)
if (!store) {
throw new Error('useDominoStore must be used within a DominoProvider')
}
return useStoreWithEqualityFn(store, selector, equalityFn)
}
/**
* Escape hatch hook to get direct access to the raw Zustand store.
* Prefer using useDominoInstance or specialized useDomino* hooks.
*/
export function useDominoStoreInstance() {
const store = useContext(DominoContext)
if (!store) {
throw new Error(
'useDominoStoreInstance must be used within a DominoProvider',
)
}
return store
}
/**
* Hook to get the current viewport data (x, y, scale)
*/
export function useDominoViewportData() {
return useDominoStore(state => state.viewport)
}
/**
* Hook to get all elements in the canvas
*/
export function useDominoElements() {
return useDominoStore(state => state.elements)
}
/**
* Hook to get the currently selected element IDs
*/
export function useDominoSelectedIds() {
return useDominoStore(state => state.selectedIds)
}
/**
* Hook to get the current interaction mode (select | pan)
*/
export function useDominoMode() {
return useDominoStore(state => state.mode)
}
/**
* Hook to get all element UI states (status, loading text, etc.)
*/
export function useDominoElementUIStates() {
return useDominoStore(state => state.elementUIStates)
}
/**
* Semantic hook to get the current selection boundary (derived state).
* Correctly handles single vs multi selection.
*/
export function useDominoSelectionBounds() {
const selectedIds = useDominoSelectedIds()
const elements = useDominoElements()
const [placeholders] = useDominoStore(state => [state.placeholders])
return useMemo(() => {
if (selectedIds.length === 0) return null
// For single selection, we can just return the element's bounds directly
// but getElementsBounds handles it correctly too.
const allItems = { ...elements, ...placeholders }
return getElementsBounds(selectedIds, allItems)
}, [selectedIds, elements, placeholders])
}
// --- 3. Internal Canvas Context Hooks ---
export const useDominoDOMContext = () => useContext(DominoDOMContext)
export const useDominoRenderHooks = () => useContext(DominoRenderHooksContext)

View File

@@ -0,0 +1,21 @@
import React, { type ReactNode, useRef } from 'react'
import type { DominoStore } from './domino-store'
import { DominoContext, DominoDOMContext } from './domino-hooks'
export function DominoProvider({
children,
store,
}: {
children: ReactNode
store: DominoStore
}) {
const containerRef = useRef<HTMLElement | null>(null)
return (
<DominoContext.Provider value={store}>
<DominoDOMContext.Provider value={containerRef}>
{children}
</DominoDOMContext.Provider>
</DominoContext.Provider>
)
}

View File

@@ -0,0 +1,637 @@
import { createStore } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
import type {
DominoState,
SceneElement,
Viewport,
Snapshot,
PlaceholderElement,
Padding,
GroupElement,
ArtboardElement,
} from './domino'
import { getElementWorldRect } from './math'
export function createSnapshot(
elements: Record<string, SceneElement>,
elementOrder: string[],
): Snapshot {
// We keep the full elementOrder to preserve placeholder positions
const filteredElements = { ...elements }
return { elements: filteredElements, elementOrder: [...elementOrder] }
}
export interface CanvasActions {
setViewport: (viewport: Partial<Viewport>) => void
addElement: (element: SceneElement) => void
removeElement: (id: string) => void
updateElement: (id: string, updates: Partial<SceneElement>) => void
updateElementUIState: (
id: string,
updates: Partial<DominoState['elementUIStates'][string]>,
) => void
setSelectedIds: (ids: string[]) => void
moveViewport: (dx: number, dy: number) => void
zoomViewport: (
scaleMultiplier: number,
centerX: number,
centerY: number,
minScale?: number,
maxScale?: number,
) => void
moveElements: (ids: string[], dx: number, dy: number) => void
setSelectionBox: (box: DominoState['selectionBox']) => void
setMode: (mode: 'select' | 'pan') => void
clearElements: (initialViewport?: Partial<Viewport>) => void
setFocusedElementId: (id: string | null) => void
setHoveredElementId: (id: string | null) => void
setSnapLines: (lines: DominoState['snapLines']) => void
moveElementUp: (id: string) => void
moveElementDown: (id: string) => void
moveElementToTop: (id: string) => void
moveElementToBottom: (id: string) => void
setPadding: (padding: Partial<Padding>) => void
undo: () => void
redo: () => void
takeSnapshot: () => void
resetHistory: () => void
addPlaceholder: (element: PlaceholderElement) => void
removePlaceholder: (id: string) => void
updatePlaceholder: (id: string, updates: Partial<PlaceholderElement>) => void
setElementsData: (
elements: Record<string, SceneElement>,
order: string[],
) => void
setReadOnly: (readOnly: boolean) => void
moveElementToParent: (
id: string,
targetParentId: string | null,
targetIndex?: number,
) => void
}
export type DominoStoreState = DominoState & CanvasActions
export type DominoStore = ReturnType<typeof createDominoStore>
export function createDominoStore(initialState?: Partial<DominoState>) {
return createStore<DominoStoreState>()(
subscribeWithSelector(
immer(set => ({
elements: {},
placeholders: {},
elementUIStates: {},
elementOrder: [],
selectedIds: [],
focusedElementId: null,
hoveredElementId: null,
selectionBox: null,
metadata: {},
mode: 'select',
viewport: { x: 0, y: 0, scale: 0.5 },
snapLines: null,
padding: { top: 0, right: 0, bottom: 0, left: 0 },
readOnly: false,
past: [],
future: [],
...initialState,
setViewport: viewport =>
set(state => {
const hasChange = Object.entries(viewport).some(
([key, value]) => state.viewport[key as keyof Viewport] !== value,
)
if (!hasChange) return
state.viewport = { ...state.viewport, ...viewport }
}),
setElementsData: (
elements: Record<string, SceneElement>,
order: string[],
) =>
set(state => {
state.elements = elements
state.elementOrder = order
}),
moveViewport: (dx, dy) =>
set(state => {
state.viewport.x += dx
state.viewport.y += dy
}),
zoomViewport: (
multiplier,
centerX,
centerY,
minScale = 0.1,
maxScale = 20,
) =>
set(state => {
const oldScale = state.viewport.scale
const newScale = Math.max(
minScale,
Math.min(maxScale, oldScale * multiplier),
)
state.viewport.x =
centerX - (centerX - state.viewport.x) * (newScale / oldScale)
state.viewport.y =
centerY - (centerY - state.viewport.y) * (newScale / oldScale)
state.viewport.scale = newScale
}),
takeSnapshot: () =>
set(state => {
if (state.readOnly) return
state.past.push(createSnapshot(state.elements, state.elementOrder))
if (state.past.length > 100) {
state.past.shift()
}
state.future = []
}),
undo: () =>
set(state => {
if (state.readOnly || state.past.length === 0) return
const previous = state.past.pop()
if (previous) {
state.future.push(
createSnapshot(state.elements, state.elementOrder),
)
// Revert elements
state.elements = previous.elements
// Smart merge elementOrder:
// We want to keep placeholders that are currently active
const currentPlaceholders = { ...state.placeholders }
state.elementOrder = previous.elementOrder.filter(
id => state.elements[id] || currentPlaceholders[id],
)
// Add back any active placeholders that weren't in the snapshot's order
Object.keys(currentPlaceholders).forEach(pid => {
if (!state.elementOrder.includes(pid)) {
state.elementOrder.push(pid)
}
})
}
}),
redo: () =>
set(state => {
if (state.readOnly) return
const next = state.future.pop()
if (next) {
state.past.push(
createSnapshot(state.elements, state.elementOrder),
)
// Revert elements
state.elements = next.elements
// Smart merge elementOrder to preserve active placeholders
const currentPlaceholders = { ...state.placeholders }
state.elementOrder = next.elementOrder.filter(
id => state.elements[id] || currentPlaceholders[id],
)
// Ensure current placeholders are still in the order
Object.keys(currentPlaceholders).forEach(pid => {
if (!state.elementOrder.includes(pid)) {
state.elementOrder.push(pid)
}
})
}
}),
resetHistory: () =>
set(state => {
state.past = []
state.future = []
}),
addElement: element =>
set(state => {
if (state.readOnly || state.elements[element.id]) return
// Record history before change
state.past.push(createSnapshot(state.elements, state.elementOrder))
state.future = []
if (state.past.length > 100) state.past.shift()
state.elements[element.id] = element
if (!element.parentId) {
if (!state.elementOrder.includes(element.id)) {
state.elementOrder.push(element.id)
}
} else {
const parent = state.elements[element.parentId]
if (
parent &&
(parent.type === 'artboard' || parent.type === 'group')
) {
if (!parent.childrenIds.includes(element.id)) {
parent.childrenIds.push(element.id)
}
}
}
}),
addPlaceholder: element =>
set(state => {
if (state.elements[element.id] || state.placeholders[element.id])
return
state.placeholders[element.id] = element
if (!element.parentId) {
if (!state.elementOrder.includes(element.id)) {
state.elementOrder.push(element.id)
}
} else {
const parent =
state.elements[element.parentId] ||
state.placeholders[element.parentId]
if (
parent &&
(parent.type === 'artboard' || parent.type === 'group')
) {
if (!parent.childrenIds.includes(element.id)) {
parent.childrenIds.push(element.id)
}
}
}
}),
removeElement: id =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (!element) return
// Record history
state.past.push(createSnapshot(state.elements, state.elementOrder))
state.future = []
if (state.past.length > 100) state.past.shift()
const { parentId } = element
delete state.elements[id]
state.elementOrder = state.elementOrder.filter(oid => oid !== id)
if (parentId) {
const parent = state.elements[parentId]
if (
parent &&
(parent.type === 'artboard' || parent.type === 'group')
) {
parent.childrenIds = parent.childrenIds.filter(
(cid: string) => cid !== id,
)
}
}
if (state.selectedIds.includes(id)) {
state.selectedIds = state.selectedIds.filter(sid => sid !== id)
}
if (state.focusedElementId === id) {
state.focusedElementId = null
}
delete state.elementUIStates[id]
}),
removePlaceholder: id =>
set(state => {
const element = state.placeholders[id]
if (!element) return
const { parentId } = element
delete state.placeholders[id]
state.elementOrder = state.elementOrder.filter(oid => oid !== id)
if (parentId) {
const parent =
state.elements[parentId] || state.placeholders[parentId]
if (
parent &&
(parent.type === 'artboard' || parent.type === 'group')
) {
parent.childrenIds = parent.childrenIds.filter(
(cid: string) => cid !== id,
)
}
}
if (state.selectedIds.includes(id)) {
state.selectedIds = state.selectedIds.filter(sid => sid !== id)
}
if (state.focusedElementId === id) {
state.focusedElementId = null
}
delete state.elementUIStates[id]
}),
updateElement: (id, updates) =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (element) {
state.elements[id] = {
...element,
...updates,
} as SceneElement
}
}),
updatePlaceholder: (id, updates) =>
set(state => {
if (state.placeholders[id]) {
state.placeholders[id] = {
...state.placeholders[id],
...updates,
}
}
}),
updateElementUIState: (id, updates) =>
set(state => {
if (!state.elementUIStates[id]) {
state.elementUIStates[id] = { status: 'idle' }
}
state.elementUIStates[id] = {
...state.elementUIStates[id],
...updates,
}
}),
setSelectedIds: ids =>
set(state => {
state.selectedIds = ids
}),
moveElements: (ids, dx, dy) =>
set(state => {
if (state.readOnly) return
ids.forEach(id => {
const el = state.elements[id]
if (el) {
el.x += dx
el.y += dy
}
})
}),
setSelectionBox: box =>
set(state => {
state.selectionBox = box
}),
setMode: mode =>
set(state => {
state.mode = mode
}),
clearElements: (initialViewport?: Partial<Viewport>) =>
set(state => {
// Record history if not already empty
if (state.elementOrder.length > 0) {
state.past.push(
createSnapshot(state.elements, state.elementOrder),
)
state.future = []
if (state.past.length > 100) state.past.shift()
}
state.elements = {}
state.placeholders = {}
state.elementUIStates = {}
state.elementOrder = []
state.selectedIds = []
state.viewport = { x: 0, y: 0, scale: 0.5, ...initialViewport }
state.mode = 'select'
state.focusedElementId = null
state.hoveredElementId = null
}),
setFocusedElementId: (id: string | null) =>
set(state => {
state.focusedElementId = id
}),
setHoveredElementId: (id: string | null) =>
set(state => {
state.hoveredElementId = id
}),
setSnapLines: lines =>
set(state => {
state.snapLines = lines
}),
moveElementUp: (id: string) =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (!element) return
const orderArray = element.parentId
? (
state.elements[element.parentId] as
| ArtboardElement
| GroupElement
)?.childrenIds
: state.elementOrder
if (!orderArray) return
const index = orderArray.indexOf(id)
if (index > -1 && index < orderArray.length - 1) {
// Record history
state.past.push(
createSnapshot(state.elements, state.elementOrder),
)
state.future = []
if (state.past.length > 100) state.past.shift()
const temp = orderArray[index]
orderArray[index] = orderArray[index + 1]
orderArray[index + 1] = temp
}
}),
moveElementDown: (id: string) =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (!element) return
const orderArray = element.parentId
? (
state.elements[element.parentId] as
| ArtboardElement
| GroupElement
)?.childrenIds
: state.elementOrder
if (!orderArray) return
const index = orderArray.indexOf(id)
if (index > 0) {
// Record history
state.past.push(
createSnapshot(state.elements, state.elementOrder),
)
state.future = []
if (state.past.length > 100) state.past.shift()
const temp = orderArray[index]
orderArray[index] = orderArray[index - 1]
orderArray[index - 1] = temp
}
}),
moveElementToTop: (id: string) =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (!element) return
const orderArray = element.parentId
? (
state.elements[element.parentId] as
| ArtboardElement
| GroupElement
)?.childrenIds
: state.elementOrder
if (!orderArray) return
const index = orderArray.indexOf(id)
if (index > -1 && index < orderArray.length - 1) {
// Record history
state.past.push(
createSnapshot(state.elements, state.elementOrder),
)
state.future = []
if (state.past.length > 100) state.past.shift()
orderArray.splice(index, 1)
orderArray.push(id)
}
}),
moveElementToBottom: (id: string) =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (!element) return
const orderArray = element.parentId
? (
state.elements[element.parentId] as
| ArtboardElement
| GroupElement
)?.childrenIds
: state.elementOrder
if (!orderArray) return
const index = orderArray.indexOf(id)
if (index > 0) {
// Record history
state.past.push(
createSnapshot(state.elements, state.elementOrder),
)
state.future = []
if (state.past.length > 100) state.past.shift()
orderArray.splice(index, 1)
orderArray.unshift(id)
}
}),
setPadding: padding =>
set(state => {
const hasChange = Object.entries(padding).some(
([key, value]) => state.padding[key as keyof Padding] !== value,
)
if (!hasChange) return
state.padding = { ...state.padding, ...padding }
}),
setReadOnly: readOnly =>
set(state => {
if (state.readOnly === readOnly) return
state.readOnly = readOnly
}),
moveElementToParent: (id, targetParentId, targetIndex) =>
set(state => {
if (state.readOnly) return
const element = state.elements[id]
if (!element || element.parentId === targetParentId) return
// 记录历史快照
state.past.push(createSnapshot(state.elements, state.elementOrder))
state.future = []
if (state.past.length > 100) state.past.shift()
// 1. 从当前父级中移除
if (!element.parentId) {
state.elementOrder = state.elementOrder.filter(oid => oid !== id)
} else {
const oldParent = state.elements[element.parentId] as
| ArtboardElement
| GroupElement
if (oldParent && oldParent.childrenIds) {
oldParent.childrenIds = oldParent.childrenIds.filter(
(cid: string) => cid !== id,
)
}
}
// 2. 计算新坐标以维持世界坐标位置不变
const worldRect = getElementWorldRect(element, state.elements)
let newX = worldRect.x
let newY = worldRect.y
if (targetParentId) {
const newParent = state.elements[targetParentId]
if (newParent) {
const parentWorld = getElementWorldRect(
newParent,
state.elements,
)
newX -= parentWorld.x
newY -= parentWorld.y
}
}
// 3. 更新父级关联和局部坐标
element.parentId = targetParentId || undefined
element.x = newX
element.y = newY
// 4. 将其添加到新父级的子元素列表或顶层列表中
if (!targetParentId) {
if (!state.elementOrder.includes(id)) {
if (typeof targetIndex === 'number') {
state.elementOrder.splice(targetIndex, 0, id)
} else {
state.elementOrder.push(id)
}
}
} else {
const newParent = state.elements[targetParentId] as
| ArtboardElement
| GroupElement
if (newParent && newParent.childrenIds) {
if (!newParent.childrenIds.includes(id)) {
if (typeof targetIndex === 'number') {
newParent.childrenIds.splice(targetIndex, 0, id)
} else {
newParent.childrenIds.push(id)
}
}
}
}
}),
})),
),
)
}

View File

@@ -0,0 +1,251 @@
export type ElementStatus =
| 'pending'
| 'idle'
| 'error'
| 'success'
| 'redrawing'
| 'readonly'
export type ElementType =
| 'text'
| 'group'
| 'artboard'
| 'image'
| 'shape'
| 'placeholder'
export interface Rect {
x: number
y: number
width: number
height: number
}
export interface ElementUIState {
status: ElementStatus
statusText?: string
[key: string]: unknown
}
export type ResizeType = 'none' | 'both' | 'horizontal' | 'vertical'
export interface BaseElement extends Rect {
id: string
type: ElementType
rotation: number
originalWidth: number
originalHeight: number
parentId?: string
hideMetadata?: boolean
lockAspectRatio?: boolean
resize?: ResizeType
rotatable?: boolean
scalable?: boolean
selectable?: boolean
draggable?: boolean
data?: {
[key: string]: unknown
}
}
export interface ArtboardElement extends BaseElement {
type: 'artboard'
name?: string
childrenIds: string[]
background?: string
}
export interface GroupElement extends BaseElement {
type: 'group'
childrenIds: string[]
}
export interface ImageElement extends BaseElement {
type: 'image'
src: string
fileName: string
data?: {
path?: string
size?: number
[key: string]: unknown
}
}
export interface TextStyle {
fontSize?: number
fontWeight?: string | number
color?: string
fontFamily?: string
lineHeight?: number
textAlign?: 'left' | 'center' | 'right'
fontStyle?: 'normal' | 'italic'
}
export interface TextElement extends BaseElement, TextStyle {
type: 'text'
content: string
}
export interface PlaceholderElement extends BaseElement {
type: 'placeholder'
label?: string
}
/**
* 暂时没有这种类型
*/
export interface ShapeElement extends BaseElement {
type: 'shape'
shapeType: 'rect' | 'ellipse' | 'polygon'
fill?: string
stroke?: string
strokeWidth?: number
}
export type SceneElement =
| ArtboardElement
| GroupElement
| ImageElement
| TextElement
| ShapeElement
| PlaceholderElement
export interface Viewport {
x: number
y: number
scale: number
}
export interface SnapLine {
type: 'x' | 'y'
value: number
}
export interface Snapshot {
elements: Record<string, SceneElement>
elementOrder: string[]
}
export interface Padding {
top?: number
right?: number
bottom?: number
left?: number
}
export type ScrollAlignment = 'start' | 'center' | 'end' | 'nearest'
export interface ScrollIntoViewOptions {
/**
* 避开 UI 的内边距
*/
padding?: Partial<Padding>
/**
* 垂直方向对齐方式
* @default 'nearest'
*/
block?: ScrollAlignment
/**
* 水平方向对齐方式
* @default 'nearest'
*/
inline?: ScrollAlignment
/**
* 是否强制平移(即使已在范围内)
*/
force?: boolean
/**
* 缩放级别,如果不提供则使用当前缩放
*/
scale?: number
}
export interface DominoState {
elements: Record<string, SceneElement>
placeholders: Record<string, PlaceholderElement>
elementUIStates: Record<string, ElementUIState>
elementOrder: string[] // Top-level elements ordering
selectedIds: string[]
focusedElementId: string | null
hoveredElementId: string | null
selectionBox: { x: number; y: number; width: number; height: number } | null
mode: 'select' | 'pan'
viewport: Viewport
snapLines: SnapLine[] | null
readOnly: boolean
// Metadata for persistence
metadata?: {
createdAt?: number
updatedAt?: number
}
padding: Padding
// History
past: Snapshot[]
future: Snapshot[]
}
export interface DominoCanvasData {
elements: Record<string, SceneElement>
elementOrder: string[]
viewport?: Viewport
createdAt: number
updatedAt: number
}
export interface DominoActions {
setViewport: (viewport: Partial<Viewport>) => void
setElementsData: (
elements: Record<string, SceneElement>,
order: string[],
) => void
setFocusedElementId: (id: string | null) => void
setHoveredElementId: (id: string | null) => void
updateElementSize: (
id: string,
width: number,
height: number,
x?: number,
y?: number,
) => void
// History Actions
undo: () => void
redo: () => void
takeSnapshot: () => void
resetHistory: () => void
setReadOnly: (readOnly: boolean) => void
}
export interface DominoEvents {
onClick?: (e: React.MouseEvent | React.PointerEvent, id?: string) => void
onSelect?: (ids: string[]) => void
onElementClick?: (id: string, element: SceneElement) => void
onTransformEnd?: (ids: string[]) => void
onViewportChange?: (viewport: Viewport) => void
onContextMenu?: (e: React.MouseEvent, id?: string) => void
onHoverChange?: (id: string | null) => void
onBeforeRemove?: (ids: string[]) => Promise<boolean> | boolean
onElementsRemove?: (ids: string[]) => void
}
export interface DominoCanvasProps extends DominoEvents {
className?: string
style?: React.CSSProperties
minScale?: number
maxScale?: number
initViewport?: Partial<Viewport>
padding?: Partial<Padding>
readOnly?: boolean
children?: React.ReactNode
// Controlled data
elements?: Record<string, SceneElement>
elementOrder?: string[]
// Render hooks
renderElement?: (props: {
element: SceneElement
defaultRender: React.ReactNode
}) => React.ReactNode
renderElementMetadata?: (props: { element: SceneElement }) => React.ReactNode
}

View File

@@ -0,0 +1,85 @@
import { memo } from 'react'
import type {
ArtboardElement,
GroupElement,
SceneElement,
TextElement,
} from './domino'
import { useDominoStore } from './domino-hooks'
import Placeholder from './placeholder'
import { TextRender } from './text-render'
import { ElementRenderer } from './element-renderer'
/**
* 核心内容渲染器:只管元素长什么样
*/
function ElementContent({ element }: { element: SceneElement }) {
const uiState = useDominoStore(state => state.elementUIStates[element.id])
switch (element.type) {
case 'image':
return (
<>
<img
src={element.src}
alt={element.fileName}
crossOrigin='anonymous'
loading='eager'
decoding='async'
className='w-full h-full object-contain pointer-events-none bg-white/5'
/>
{uiState?.status === 'pending' && (
<Placeholder className='z-1 h-full w-full'>
<span
className='text-white'
style={{ fontSize: 'calc(14px / var(--domino-scale))' }}
>
{uiState?.statusText}
</span>
</Placeholder>
)}
</>
)
case 'text': {
return <TextRender element={element as TextElement} />
}
case 'group': {
const childrenIds = (element as GroupElement).childrenIds
return (
<div className='relative'>
{childrenIds.map((childId: string) => (
<ElementRenderer key={childId} id={childId} />
))}
</div>
)
}
case 'artboard': {
const childrenIds = (element as ArtboardElement).childrenIds
return (
<div
className='relative w-full h-full'
style={{
background: element.background || '#fff',
overflow: 'hidden',
}}
>
{childrenIds.map((childId: string) => (
<ElementRenderer key={childId} id={childId} />
))}
</div>
)
}
case 'placeholder':
return (
<Placeholder>
<span style={{ fontSize: 'calc(16px / var(--domino-scale))' }}>
{element.label}
</span>
</Placeholder>
)
default:
return null
}
}
export const MemoizedElementContent = memo(ElementContent)

View File

@@ -0,0 +1,189 @@
import React, { useEffect, useRef } from 'react'
import { cn } from '@/utils/cn'
import { type SceneElement } from './domino'
import { DOMINO_EL_PREFIX } from './constants'
import {
useDominoStore,
useDominoStoreInstance,
useDominoRenderHooks,
} from './domino-hooks'
import { MemoizedElementContent } from './element-content'
export interface ElementRendererProps {
id: string
}
/**
* 统一交互外壳:处理坐标、选中、悬停、焦点、递归
*/
export const ElementRenderer: React.FC<ElementRendererProps> = React.memo(
function ElementRenderer({ id }) {
const store = useDominoStoreInstance()
const element = useDominoStore(
state => state.elements[id] || state.placeholders[id],
)
const isFocused = useDominoStore(state => state.focusedElementId === id)
const setHoveredElementId = useDominoStore(
state => state.setHoveredElementId,
)
const setFocusedElementId = useDominoStore(
state => state.setFocusedElementId,
)
const { renderElement } = useDominoRenderHooks()
const elementRef = useRef<HTMLDivElement>(null)
useEffect(() => {
// For text elements, we want the inner contentEditable to have focus, not the wrapper
if (!element || element?.type === 'text') return
if (isFocused && document.activeElement !== elementRef.current) {
elementRef.current?.focus({ preventScroll: true })
}
}, [isFocused, element?.type])
if (!element) return null
// 1. 统一位置样式
const style: React.CSSProperties = {
width: element.type === 'text' && !element.width ? 'auto' : element.width,
height: element.type === 'text' ? 'auto' : element.height,
minHeight: element.type === 'text' ? element.height : undefined,
minWidth: element.type === 'text' && !element.width ? 20 : undefined,
transform: `translate3d(${element.x}px, ${element.y}px, 0) rotate(${element.rotation}deg)`,
overflow: 'visible',
zIndex: element.type === 'artboard' ? -1 : undefined,
}
// 2. 统一交互逻辑
const handleFocus = (e: React.FocusEvent) => {
e.stopPropagation()
if (store.getState().focusedElementId === id) return
setFocusedElementId(id)
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation()
// If text is already focused, bale out to allow native click behavior (move cursor)
if (element.type === 'text' && isFocused) {
return
}
// If clicking non-text, clear focus (Interactions took care of selection)
if (element.type !== 'text') {
setFocusedElementId(null)
}
}
const handleDoubleClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (element.type === 'text') {
setFocusedElementId(id)
}
}
const defaultContent = <MemoizedElementContent element={element} />
const content = renderElement
? renderElement({ element, defaultRender: defaultContent })
: defaultContent
return (
<div
id={DOMINO_EL_PREFIX + id}
ref={elementRef}
tabIndex={element.type === 'text' ? undefined : 0}
className={cn(
'domino-element absolute flex flex-col pointer-events-auto box-border',
element.type === 'text'
? 'items-start justify-start'
: 'items-center justify-center',
)}
style={style}
onPointerEnter={() => setHoveredElementId(id)}
onPointerLeave={() => setHoveredElementId(null)}
onFocus={handleFocus}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
{/* A. 装饰层 (仅外部钩子存在时渲染壳) */}
<ElementMetadataWrapper element={element} />
{/* B. 内容渲染器 */}
{content}
</div>
)
},
)
/**
* 元数据定位壳,完全由业务层提供内容
*/
function ElementMetadataWrapper({ element }: { element: SceneElement }) {
const { renderElementMetadata } = useDominoRenderHooks()
// 1. 昂贵的碰撞检测逻辑依然保留在内核,用于自动隐藏可能被遮挡的元信息
const isMetadataHidden = useDominoStore(
state => {
// 如果业务层未定义钩子,虽然这个 Hook 会运行,但我们让他返回一个简单值
if (!renderElementMetadata) return true
// 画板元信息通常总是显示,但业务层也可以自行控制其内容
if (element.type === 'artboard') return false
// 目前主要为图片和画板支持元信息展示
if (element.type !== 'image') return true
const el = state.elements[element.id] || state.placeholders[element.id]
if (!el) return true
// 检测是否被其他元素遮盖了元信息显示区域
const metaHeight = 22
const metaYMin = el.y - metaHeight
const metaYMax = el.y
const metaXMin = el.x
const metaXMax = el.x + el.width
const allElements = [
...Object.values(state.elements),
...Object.values(state.placeholders),
]
return allElements.some(other => {
if (other.id === el.id || other.id === el.parentId) return false
const otherXMax = other.x + (other.width || 0)
const otherYMax = other.y + (other.height || 0)
return (
!(otherXMax < metaXMin || other.x > metaXMax) &&
!(otherYMax < metaYMin || other.y > metaYMax)
)
})
},
(a, b) => a === b,
)
// 2. 只有定义了钩子且未被隐藏时才渲染
if (!renderElementMetadata || isMetadataHidden || element.hideMetadata)
return null
const renderedContent = renderElementMetadata({ element })
if (!renderedContent) return null
return (
<div
className='domino-element-metadata absolute left-0 w-full z-50 pointer-events-none'
style={{
bottom: '100%',
width: 'calc(var(--domino-scale) * 100%)',
transform: 'scale(calc(1 / var(--domino-scale)))',
transformOrigin: '0 100%',
paddingBottom: 4,
fontSize: 11,
}}
>
{renderedContent}
</div>
)
}

View File

@@ -0,0 +1,12 @@
export * from './domino'
export * from './domino-canvas'
export * from './domino-store'
export * from './domino-hooks'
export * from './domino-provider'
export * from './use-domino-instance'
export * from './use-domino-container'
export * from './use-domino-anchor'
export * from './domino-anchor'
export * from './constants'
export * from './math'
export * from './dom-utils'

View File

@@ -0,0 +1,184 @@
import type {
SceneElement,
TextElement,
ImageElement,
PlaceholderElement,
} from '../domino'
interface BoundingBox {
x: number
y: number
width: number
height: number
fontSize?: number
}
export function calculateResize(
id: string,
startEl: BoundingBox,
el: SceneElement | PlaceholderElement,
handle: string,
totalWdx: number,
totalWdy: number,
scale: number,
shiftKey: boolean,
altKey: boolean,
) {
const rad = ((el.rotation || 0) * Math.PI) / 180
const cos = Math.cos(rad)
const sin = Math.sin(rad)
const ldx = totalWdx * cos + totalWdy * sin
const ldy = -totalWdx * sin + totalWdy * cos
let isProportional = el.lockAspectRatio !== false
const isSideHandle =
handle === 'ml' || handle === 'mr' || handle === 'mt' || handle === 'mb'
if (isSideHandle) {
isProportional = false
}
if (shiftKey) {
isProportional = !isProportional
}
const ratio = startEl.width / (startEl.height || 1)
let mw = 0
let mh = 0
let fx = 0
let fy = 0
if (handle === 'nw') {
mw = -1
mh = -1
fx = 0.5
fy = 0.5
} else if (handle === 'ne') {
mw = 1
mh = -1
fx = -0.5
fy = 0.5
} else if (handle === 'sw') {
mw = -1
mh = 1
fx = 0.5
fy = -0.5
} else if (handle === 'se') {
mw = 1
mh = 1
fx = -0.5
fy = -0.5
} else if (handle === 'ml') {
mw = -1
mh = 0
fx = 0.5
fy = 0
} else if (handle === 'mr') {
mw = 1
mh = 0
fx = -0.5
fy = 0
} else if (handle === 'mt') {
mw = 0
mh = -1
fx = 0
fy = 0.5
} else if (handle === 'mb') {
mw = 0
mh = 1
fx = 0
fy = -0.5
}
let targetW = startEl.width + mw * ldx
let targetH = startEl.height + mh * ldy
if (isProportional) {
if (!isSideHandle) {
if (Math.abs(mw * ldx) > Math.abs(mh * ldy)) {
targetH = targetW / ratio
} else {
targetW = targetH * ratio
}
}
}
let isSnappedToOriginal = false
if (el.type === 'image' && !altKey) {
const img = el as ImageElement
if (img.originalWidth && img.originalHeight) {
const snapThreshold = 5 / scale
const distW = Math.abs(targetW - img.originalWidth)
const distH = Math.abs(targetH - img.originalHeight)
if (distW < snapThreshold || distH < snapThreshold) {
targetW = img.originalWidth
targetH = img.originalHeight
isSnappedToOriginal = true
}
}
}
const minSize = 20
if (targetW < minSize) targetW = minSize
if (targetH < minSize) targetH = minSize
if (isProportional) {
if (targetW / targetH > ratio) {
targetW = targetH * ratio
} else {
targetH = targetW / ratio
}
}
const startCx = startEl.x + startEl.width / 2
const startCy = startEl.y + startEl.height / 2
const fpx = startCx + (fx * startEl.width * cos - fy * startEl.height * sin)
const fpy = startCy + (fx * startEl.width * sin + fy * startEl.height * cos)
const ncx = fpx - (fx * targetW * cos - fy * targetH * sin)
const ncy = fpy - (fx * targetW * sin + fy * targetH * cos)
const newX = ncx - targetW / 2
const newY = ncy - targetH / 2
if (el.type === 'text') {
const isCorner = !isSideHandle
const startFontSize = startEl.fontSize || 16
let finalW = targetW
let finalFontSize = (el as TextElement).fontSize
let finalH = isCorner ? targetW / ratio : startEl.height
if (isCorner) {
finalFontSize = Math.round(startFontSize * (targetW / startEl.width))
finalW = (finalFontSize / startFontSize) * startEl.width
finalH = finalW / ratio
} else {
finalW = targetW + 0.5
}
const ncx = fpx - (fx * finalW * cos - fy * finalH * sin)
const ncy = fpy - (fx * finalW * sin + fy * finalH * cos)
return {
updates: {
x: ncx - finalW / 2,
y: ncy - finalH / 2,
width: finalW,
fontSize: finalFontSize,
height: isCorner ? finalH : undefined,
},
isSnappedToOriginal,
}
}
return {
updates: {
x: newX,
y: newY,
width: targetW,
height: targetH,
},
isSnappedToOriginal,
}
}

View File

@@ -0,0 +1,156 @@
import { getElementWorldRect } from '../math'
import type { SceneElement, SnapLine, PlaceholderElement } from '../domino'
interface BoundingBox {
x: number
y: number
width: number
height: number
}
export function calculateSnap(
selectedIds: string[],
dragStartElements: Record<string, BoundingBox>,
elements: Record<string, SceneElement>,
placeholders: Record<string, PlaceholderElement>,
totalDx: number,
totalDy: number,
scale: number,
shouldSnap: boolean,
) {
let snapOffsetX = 0
let snapOffsetY = 0
const activeSnapLines: SnapLine[] = []
if (!shouldSnap || selectedIds.length === 0) {
return { snapOffsetX, snapOffsetY, finalSnapLines: null }
}
// 1. 计算选中项在世界坐标系下的“理想包围盒” (无吸附时的位置)
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
selectedIds.forEach(id => {
const start = dragStartElements[id]
const rawEl = elements[id] || placeholders[id]
if (start && rawEl) {
// 获取父级当前的绝对位置(如果父级也在动,这里能拿到动之后的位置)
let parentWorldX = 0
let parentWorldY = 0
let curr = rawEl
while (curr.parentId) {
const parent = elements[curr.parentId]
if (!parent) break
parentWorldX += parent.x
parentWorldY += parent.y
curr = parent
}
// 理想世界位置 = 当前父级位置 + 初始局部位置 + 鼠标偏移
const idealWorldX = parentWorldX + start.x + totalDx
const idealWorldY = parentWorldY + start.y + totalDy
minX = Math.min(minX, idealWorldX)
minY = Math.min(minY, idealWorldY)
maxX = Math.max(maxX, idealWorldX + rawEl.width)
maxY = Math.max(maxY, idealWorldY + rawEl.height)
}
})
const selectionRect = {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
}
const threshold = 5 / scale // 5px 屏幕空间阈值
// 2. 找到画布上所有“其他”元素的世界位置作为吸附目标
const others = [
...Object.values(elements),
...Object.values(placeholders),
].filter(el => !selectedIds.includes(el.id) && el.type !== 'artboard')
let bestDistX = threshold
let bestDistY = threshold
const movingBounds = {
left: selectionRect.x,
right: selectionRect.x + selectionRect.width,
centerX: selectionRect.x + selectionRect.width / 2,
top: selectionRect.y,
bottom: selectionRect.y + selectionRect.height,
centerY: selectionRect.y + selectionRect.height / 2,
}
others.forEach(other => {
const worldOther = getElementWorldRect(other, elements)
const otherBounds = {
left: worldOther.x,
right: worldOther.x + worldOther.width,
centerX: worldOther.x + worldOther.width / 2,
top: worldOther.y,
bottom: worldOther.y + worldOther.height,
centerY: worldOther.y + worldOther.height / 2,
}
// 比对 X 方向 (左、中、右)
const mX = [movingBounds.left, movingBounds.centerX, movingBounds.right]
const oX = [otherBounds.left, otherBounds.centerX, otherBounds.right]
mX.forEach(mx => {
oX.forEach(ox => {
const d = Math.abs(mx - ox)
if (d < bestDistX) {
bestDistX = d
snapOffsetX = ox - mx
activeSnapLines.push({ type: 'x', value: ox })
}
})
})
// 比对 Y 方向 (上、中、下)
const mY = [movingBounds.top, movingBounds.centerY, movingBounds.bottom]
const oY = [otherBounds.top, otherBounds.centerY, otherBounds.bottom]
mY.forEach(my => {
oY.forEach(oy => {
const d = Math.abs(my - oy)
if (d < bestDistY) {
bestDistY = d
snapOffsetY = oy - my
activeSnapLines.push({ type: 'y', value: oy })
}
})
})
})
// 3. 过滤并返回最终的吸附线
const finalSnapLines = activeSnapLines.filter(line => {
if (line.type === 'x') {
const currentL = minX + snapOffsetX
const currentC = minX + (maxX - minX) / 2 + snapOffsetX
const currentR = maxX + snapOffsetX
return (
Math.abs(line.value - currentL) < 0.01 ||
Math.abs(line.value - currentC) < 0.01 ||
Math.abs(line.value - currentR) < 0.01
)
} else {
const currentT = minY + snapOffsetY
const currentC = minY + (maxY - minY) / 2 + snapOffsetY
const currentB = maxY + snapOffsetY
return (
Math.abs(line.value - currentT) < 0.01 ||
Math.abs(line.value - currentC) < 0.01 ||
Math.abs(line.value - currentB) < 0.01
)
}
})
return {
snapOffsetX,
snapOffsetY,
finalSnapLines: finalSnapLines.length > 0 ? finalSnapLines : null,
}
}

View File

@@ -0,0 +1,227 @@
import type { Padding, SceneElement, Viewport } from './domino'
/**
* 计算元素移动到安全视野内的 Viewport 变换 (纯算法,无 DOM 依赖)
*/
export function calculateScrollIntoViewTransform(
element: SceneElement,
viewport: Viewport,
containerRect: { width: number; height: number },
padding: Required<Padding>,
options: {
force?: boolean
block?: 'start' | 'center' | 'end' | 'nearest'
inline?: 'start' | 'center' | 'end' | 'nearest'
targetScale?: number
},
): Viewport | null {
const { x: currentX, y: currentY, scale: currentScale } = viewport
const { force, block = 'nearest', inline = 'nearest', targetScale } = options
const scale = targetScale ?? currentScale
// 计算安全区域
const safeLeft = padding.left
const safeTop = padding.top
const safeRight = containerRect.width - padding.right
const safeBottom = containerRect.height - padding.bottom
const safeWidth = safeRight - safeLeft
const safeHeight = safeBottom - safeTop
// 元素即时边界
const elW = element.width * scale
const elH = element.height * scale
const elLeft = element.x * scale + currentX
const elRight = elLeft + elW
const elTop = element.y * scale + currentY
const elBottom = elTop + elH
const isXOutOfView = force || elLeft < safeLeft || elRight > safeRight
const isYOutOfView = force || elTop < safeTop || elBottom > safeBottom
let nextX = currentX
let nextY = currentY
if (isXOutOfView) {
if (inline === 'center') {
nextX = safeLeft + (safeWidth - elW) / 2 - element.x * scale
} else if (
inline === 'start' ||
(inline === 'nearest' && elLeft < safeLeft)
) {
nextX = safeLeft - element.x * scale
} else if (
inline === 'end' ||
(inline === 'nearest' && elRight > safeRight)
) {
nextX = safeRight - elW - element.x * scale
}
}
if (isYOutOfView) {
if (block === 'center') {
nextY = safeTop + (safeHeight - elH) / 2 - element.y * scale
} else if (block === 'start' || (block === 'nearest' && elTop < safeTop)) {
nextY = safeTop - element.y * scale
} else if (
block === 'end' ||
(block === 'nearest' && elBottom > safeBottom)
) {
nextY = safeBottom - elH - element.y * scale
}
}
const hasChange =
nextX !== currentX ||
nextY !== currentY ||
(targetScale !== undefined && targetScale !== currentScale)
return hasChange ? { x: nextX, y: nextY, scale } : null
}
/**
* 计算适应屏幕的 Viewport 变换 (纯算法,无 DOM 依赖)
*/
export function calculateZoomToFitTransform(
elements: SceneElement[],
containerRect: { width: number; height: number },
padding: Required<Padding>,
): Viewport | null {
if (elements.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
elements.forEach(el => {
minX = Math.min(minX, el.x)
minY = Math.min(minY, el.y)
maxX = Math.max(maxX, el.x + el.width)
maxY = Math.max(maxY, el.y + el.height)
})
const contentWidth = maxX - minX
const contentHeight = maxY - minY
const safeWidth = containerRect.width - padding.left - padding.right
const safeHeight = containerRect.height - padding.top - padding.bottom
if (safeWidth <= 0 || safeHeight <= 0) return null
const scaleX = safeWidth / contentWidth
const scaleY = safeHeight / contentHeight
const targetScale = Math.max(
0.02,
Math.min(4, Math.min(scaleX, scaleY) * 0.95),
)
const nextX =
padding.left +
(safeWidth - contentWidth * targetScale) / 2 -
minX * targetScale
const nextY =
padding.top +
(safeHeight - contentHeight * targetScale) / 2 -
minY * targetScale
return { x: nextX, y: nextY, scale: targetScale }
}
/**
* 计算元素的绝对位置和旋转 (世界坐标)
*/
export function getElementWorldRect(
element: SceneElement,
elements: Record<string, SceneElement>,
) {
let x = element.x
let y = element.y
let rotation = element.rotation || 0
let curr = element
const visited = new Set<string>([element.id])
while (curr.parentId) {
if (visited.has(curr.parentId)) {
console.warn(
'Circular dependency detected in element tree',
curr.parentId,
)
break
}
const parent = elements[curr.parentId]
if (!parent) break
x += parent.x
y += parent.y
rotation += parent.rotation || 0
visited.add(curr.parentId)
curr = parent
}
return {
x,
y,
width: element.width,
height: element.height,
rotation,
}
}
/**
* 计算一组元素的包围盒 (考虑旋转和父级偏移)
*/
export function getElementsBounds(
ids: string[],
elements: Record<string, SceneElement>,
) {
if (ids.length === 0) return null
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
ids.forEach(id => {
const rawEl = elements[id]
if (!rawEl) return
const el = getElementWorldRect(rawEl, elements)
const rad = (el.rotation * Math.PI) / 180
const cx = el.x + el.width / 2
const cy = el.y + el.height / 2
const corners = [
[-el.width / 2, -el.height / 2],
[el.width / 2, -el.height / 2],
[el.width / 2, el.height / 2],
[-el.width / 2, el.height / 2],
]
corners.forEach(([px, py]) => {
const rx = px * Math.cos(rad) - py * Math.sin(rad)
const ry = px * Math.sin(rad) + py * Math.cos(rad)
const wx = rx + cx
const wy = ry + cy
minX = Math.min(minX, wx)
minY = Math.min(minY, wy)
maxX = Math.max(maxX, wx)
maxY = Math.max(maxY, wy)
})
})
if (minX === Infinity) return null
return {
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
right: maxX,
bottom: maxY,
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
}
}

View File

@@ -0,0 +1,30 @@
import { type CSSProperties, memo } from 'react'
import { cn } from '@/utils/cn'
export interface PlaceholderProps {
className?: string
style?: CSSProperties
children?: React.ReactNode
onClick?: (e: React.MouseEvent) => void
}
function Placeholder(props: PlaceholderProps) {
const { className, style, children, onClick } = props
return (
<div className='absolute z-1 w-full h-full flex items-center justify-center overflow-hidden'>
<div
className={cn(
'animate-pulse',
'absolute top-0 left-0 backdrop-blur-11px w-100% h-100% flex items-center justify-center text-white font-w-[16px] font-weight-500',
'bg-[radial-gradient(241%_81%_at_3%_0%,rgba(123,97,255,0.4)_0%,rgba(255,255,255,0)_100%),linear-gradient(143deg,rgba(255,255,255,0.7)_4%,rgba(97,205,255,0.7)_99%)]',
className,
)}
style={style}
onClick={onClick}
></div>
<span className='text-white z-1'>{children}</span>
</div>
)
}
export default memo(Placeholder)

View File

@@ -0,0 +1,484 @@
import React, { memo } from 'react'
import { cn } from '@/utils/cn'
import { useDominoStore, useDominoSelectionBounds } from './domino-hooks'
import { getElementWorldRect } from './math'
import type { ResizeType } from './domino'
import { icons } from '../ui/icon-mapping'
const getIconDataUri = (path: string) => {
const content = icons[path]
if (!content) return ''
try {
// Standard SVG data URI with base64 for maximum compatibility
if (typeof window !== 'undefined') {
return `data:image/svg+xml;base64,${window.btoa(unescape(encodeURIComponent(content)))}`
}
} catch (e) {
console.error(`Failed to encode icon ${path}`, e)
}
return `data:image/svg+xml,${encodeURIComponent(content)}`
}
// --- Sub-components (Fine-grained Subscriptions) ---
const HoverBorder = memo(() => {
const hoveredElementId = useDominoStore(state => state.hoveredElementId)
const isSelected = useDominoStore(state =>
hoveredElementId ? state.selectedIds.includes(hoveredElementId) : false,
)
const elements = useDominoStore(state => state.elements)
const el = hoveredElementId ? elements[hoveredElementId] : null
if (!el || isSelected) return null
const worldRect = getElementWorldRect(el, elements)
return (
<div
className='absolute pointer-events-none z-[98]'
style={{
width: worldRect.width,
height: worldRect.height,
transform: `translate3d(${worldRect.x}px, ${worldRect.y}px, 0) rotate(${worldRect.rotation}deg)`,
borderWidth: 'max(0.2px, calc(1px / var(--domino-scale)))',
borderStyle: 'solid',
borderColor: 'var(--editor-accent)',
}}
/>
)
})
const FocusBorder = memo(() => {
const focusedElementId = useDominoStore(state => state.focusedElementId)
const isSelected = useDominoStore(state =>
focusedElementId ? state.selectedIds.includes(focusedElementId) : false,
)
const elements = useDominoStore(state => state.elements)
const el = focusedElementId ? elements[focusedElementId] : null
if (!el || isSelected || el.type === 'text') return null
const worldRect = getElementWorldRect(el, elements)
return (
<div
className='absolute pointer-events-none z-[99]'
style={{
width: worldRect.width,
height: worldRect.height,
transform: `translate3d(${worldRect.x}px, ${worldRect.y}px, 0) rotate(${worldRect.rotation}deg)`,
borderWidth: 'max(0.4px, calc(1.5px / var(--domino-scale)))',
borderStyle: 'solid',
borderColor: 'color-mix(in srgb, var(--editor-accent) 62%, transparent)',
}}
/>
)
})
const MultiSelectionOverlay = memo(() => {
const selectedIds = useDominoStore(state => state.selectedIds)
const selectionBounds = useDominoSelectionBounds()
const readOnly = useDominoStore(state => state.readOnly)
if (selectedIds.length <= 1 || !selectionBounds) return null
return (
<div
className={cn(
'domino-multi-select-area absolute pointer-events-auto z-[100]',
!readOnly && 'cursor-move',
)}
data-multi-select='true'
style={{
transform: `translate3d(${selectionBounds.left}px, ${selectionBounds.top}px, 0)`,
width: selectionBounds.width,
height: selectionBounds.height,
borderWidth: 'max(0.2px, calc(1px / var(--domino-scale)))',
borderStyle: 'solid',
borderColor: 'var(--editor-accent)',
}}
/>
)
})
function RotateHandles() {
const nwRotateIcon = getIconDataUri('../../icons/nw-rotate.svg')
const neRotateIcon = getIconDataUri('../../icons/ne-rotate.svg')
const swRotateIcon = getIconDataUri('../../icons/sw-rotate.svg')
const seRotateIcon = getIconDataUri('../../icons/se-rotate.svg')
const rotateOffset = 'calc(-12px / var(--domino-scale))'
const totalRotateSize = 'calc(22px / var(--domino-scale))'
const rotateHandleClassName =
'domino-rotate-handle absolute pointer-events-auto overflow-visible'
return (
<span>
{/* NW (左上): 避开右下象限 */}
<svg
data-rotate='nw'
className={rotateHandleClassName}
viewBox='0 0 21 21'
style={{
top: rotateOffset,
left: rotateOffset,
width: totalRotateSize,
height: totalRotateSize,
cursor: `url("${nwRotateIcon}") 12 12, auto`
}}
>
<path
d='M 10.5 10.5 L 10.5 21 A 10.5 10.5 0 1 1 21 10.5 Z'
fill='transparent'
className='pointer-events-auto'
/>
</svg>
{/* NE (右上): 避开左下象限 */}
<svg
data-rotate='ne'
className={rotateHandleClassName}
viewBox='0 0 21 21'
style={{
top: rotateOffset,
right: rotateOffset,
width: totalRotateSize,
height: totalRotateSize,
cursor: `url("${neRotateIcon}") 12 12, auto`,
}}
>
<path
d='M 9.5 10.5 L -1 10.5 A 10.5 10.5 0 1 1 9.5 21 Z'
fill='transparent'
className='pointer-events-auto'
/>
</svg>
{/* SW (左下): 避开右上象限 */}
<svg
data-rotate='sw'
className={rotateHandleClassName}
viewBox='0 0 21 21'
style={{
bottom: rotateOffset,
left: rotateOffset,
width: totalRotateSize,
height: totalRotateSize,
cursor: `url("${swRotateIcon}") 12 12, auto`,
}}
>
<path
d='M 10.5 9.5 L 21 9.5 A 10.5 10.5 0 1 1 10.5 -1 Z'
fill='transparent'
className='pointer-events-auto'
/>
</svg>
{/* SE (右下): 避开左上象限 */}
<svg
data-rotate='se'
className={rotateHandleClassName}
viewBox='0 0 21 21'
style={{
bottom: rotateOffset,
right: rotateOffset,
width: totalRotateSize,
height: totalRotateSize,
cursor: `url("${seRotateIcon}") 12 12, auto`,
}}
>
<path
d='M 9.5 9.5 L 9.5 -1 A 10.5 10.5 0 1 1 -1 9.5 Z'
fill='transparent'
className='pointer-events-auto'
/>
</svg>
</span>
)
}
function ResizeHandles() {
const hitHandleSize = 'calc(14px / var(--domino-scale))'
const hitOffset = 'calc(-7.5px / var(--domino-scale))'
const visualHandleStyle: React.CSSProperties = {
width: 'calc(12px / var(--domino-scale))',
height: 'calc(12px / var(--domino-scale))',
borderWidth: 'calc(2px / var(--domino-scale))',
borderStyle: 'solid',
borderRadius: '50%',
boxShadow:
'0 calc(1px / var(--domino-scale)) calc(3px / var(--domino-scale)) var(--editor-shadow)',
background: 'var(--editor-surface)',
borderColor: 'var(--editor-accent)',
}
const hitHandleClassName =
'domino-resize-handle box-border absolute pointer-events-auto z-10 flex items-center justify-center rounded-50%'
const visualHandleClassName = ''
return (
<>
<div
data-handle='nw'
className={cn(hitHandleClassName, 'cursor-nwse-resize')}
style={{
width: hitHandleSize,
height: hitHandleSize,
top: hitOffset,
left: hitOffset,
}}
>
<div className={visualHandleClassName} style={visualHandleStyle} />
</div>
<div
data-handle='ne'
className={cn(hitHandleClassName, 'cursor-nesw-resize')}
style={{
width: hitHandleSize,
height: hitHandleSize,
top: hitOffset,
right: hitOffset,
}}
>
<div className={visualHandleClassName} style={visualHandleStyle} />
</div>
<div
data-handle='sw'
className={cn(hitHandleClassName, 'cursor-nesw-resize')}
style={{
width: hitHandleSize,
height: hitHandleSize,
bottom: hitOffset,
left: hitOffset,
}}
>
<div className={visualHandleClassName} style={visualHandleStyle} />
</div>
<div
data-handle='se'
className={cn(hitHandleClassName, 'cursor-nwse-resize')}
style={{
width: hitHandleSize,
height: hitHandleSize,
bottom: hitOffset,
right: hitOffset,
}}
>
<div className={visualHandleClassName} style={visualHandleStyle} />
</div>
</>
)
}
function SideHandles({
resize,
width,
height,
scale,
}: {
resize?: ResizeType
width: number
height: number
scale: number
}) {
if (resize === 'none') return null
const hitHandleSize = 'calc(14px / var(--domino-scale))'
const hitOffset = 'calc(-7.5px / var(--domino-scale))'
const sideHandleStyle: React.CSSProperties = {
width: 'calc(24px / var(--domino-scale))',
height: 'calc(8px / var(--domino-scale))',
backgroundColor: 'var(--editor-surface)',
border: 'calc(2px / var(--domino-scale)) solid var(--editor-accent)',
borderRadius: 999,
boxShadow:
'0 calc(1px / var(--domino-scale)) calc(3px / var(--domino-scale)) var(--editor-shadow)',
}
const sideHandleVerticalStyle: React.CSSProperties = {
width: 'calc(8px / var(--domino-scale))',
height: 'calc(24px / var(--domino-scale))',
backgroundColor: 'var(--editor-surface)',
border: 'calc(2px / var(--domino-scale)) solid var(--editor-accent)',
borderRadius: 999,
boxShadow:
'0 calc(1px / var(--domino-scale)) calc(3px / var(--domino-scale)) var(--editor-shadow)',
}
const hitHandleClassName =
'domino-resize-handle box-border absolute pointer-events-auto z-10 flex items-center justify-center'
const VISUAL_THRESHOLD = 38 // 屏幕像素阈值
const canShowVertical = width * scale > VISUAL_THRESHOLD
const canShowHorizontal = height * scale > VISUAL_THRESHOLD
const showVertical =
(resize === 'both' || resize === 'vertical' || !resize) && canShowVertical
const showHorizontal =
(resize === 'both' || resize === 'horizontal' || !resize) &&
canShowHorizontal
return (
<>
{/* Middle Top */}
{showVertical && (
<div
data-handle='mt'
className={cn(hitHandleClassName, 'cursor-ns-resize')}
style={{
width: 'calc(24px / var(--domino-scale))',
height: hitHandleSize,
top: hitOffset,
left: '50%',
transform: 'translateX(-50%)',
}}
>
<div style={sideHandleStyle} />
</div>
)}
{/* Middle Bottom */}
{showVertical && (
<div
data-handle='mb'
className={cn(hitHandleClassName, 'cursor-ns-resize')}
style={{
width: 'calc(24px / var(--domino-scale))',
height: hitHandleSize,
bottom: hitOffset,
left: '50%',
transform: 'translateX(-50%)',
}}
>
<div style={sideHandleStyle} />
</div>
)}
{/* Middle Left */}
{showHorizontal && (
<div
data-handle='ml'
className={cn(hitHandleClassName, 'cursor-ew-resize')}
style={{
width: hitHandleSize,
height: 'calc(24px / var(--domino-scale))',
left: hitOffset,
top: '50%',
transform: 'translateY(-50%)',
}}
>
<div style={sideHandleVerticalStyle} />
</div>
)}
{/* Middle Right */}
{showHorizontal && (
<div
data-handle='mr'
className={cn(hitHandleClassName, 'cursor-ew-resize')}
style={{
width: hitHandleSize,
height: 'calc(24px / var(--domino-scale))',
right: hitOffset,
top: '50%',
transform: 'translateY(-50%)',
}}
>
<div style={sideHandleVerticalStyle} />
</div>
)}
</>
)
}
const SingleSelectionOverlay = memo(() => {
const selectedIds = useDominoStore(state => state.selectedIds)
const elementId = selectedIds.length === 1 ? selectedIds[0] : null
const elements = useDominoStore(state => state.elements)
const el = elementId ? elements[elementId] : null
const uiState = useDominoStore(state =>
elementId ? state.elementUIStates[elementId] : null,
)
const scale = useDominoStore(state => state.viewport.scale)
const readOnly = useDominoStore(state => state.readOnly)
if (!el) return null
const isLocked = readOnly || uiState?.status === 'readonly'
const worldRect = getElementWorldRect(el, elements)
return (
<div
className='absolute pointer-events-none z-[100]'
style={{
width: worldRect.width,
height: worldRect.height,
transform: `translate3d(${worldRect.x}px, ${worldRect.y}px, 0) rotate(${worldRect.rotation}deg)`,
borderWidth: 'max(0.4px, calc(2px / var(--domino-scale)))',
borderStyle: 'solid',
borderColor: 'var(--editor-accent)',
}}
>
{!isLocked && (
<>
{el.rotatable !== false && <RotateHandles />}
{el.scalable !== false && (
<>
<ResizeHandles />
{el.resize !== 'none' &&
(el.lockAspectRatio === false || el.resize) && (
<SideHandles
resize={el.resize}
width={el.width}
height={el.height}
scale={scale}
/>
)}
</>
)}
</>
)}
</div>
)
})
const SnapLinesOverlay = memo(() => {
const snapLines = useDominoStore(state => state.snapLines)
if (!snapLines || snapLines.length === 0) return null
return (
<>
{snapLines.map((line, i) => (
<div
key={`${line.type}-${line.value}-${i}`}
className='absolute border-[#FF00FF] pointer-events-none z-[120]'
style={{
left: line.type === 'x' ? line.value : -10000,
top: line.type === 'y' ? line.value : -10000,
width:
line.type === 'x' ? 'calc(1px / var(--domino-scale))' : 20000,
height:
line.type === 'y' ? 'calc(1px / var(--domino-scale))' : 20000,
borderStyle: 'dashed',
borderWidth:
line.type === 'x'
? '0 0 0 calc(1px / var(--domino-scale))'
: 'calc(1px / var(--domino-scale)) 0 0 0',
}}
/>
))}
</>
)
})
// --- Main Component ---
export const SelectionOverlay: React.FC = memo(() => {
return (
<>
<HoverBorder />
<FocusBorder />
<MultiSelectionOverlay />
<SingleSelectionOverlay />
<SnapLinesOverlay />
</>
)
})
SelectionOverlay.displayName = 'SelectionOverlay'

View File

@@ -0,0 +1,108 @@
import React, { useEffect, useRef } from 'react'
import { cn } from '@/utils/cn'
import type { TextElement } from './domino'
import { useDominoStore } from './domino-hooks'
interface TextRenderProps {
element: TextElement
}
export function TextRender({ element }: TextRenderProps) {
const updateElement = useDominoStore(state => state.updateElement)
const setFocusedElementId = useDominoStore(state => state.setFocusedElementId)
const isFocused = useDominoStore(
state => state.focusedElementId === element.id,
)
const isSelected = useDominoStore(state =>
state.selectedIds.includes(element.id),
)
const isEditing = isFocused && !isSelected
const textRef = useRef<HTMLDivElement>(null)
const prevFocused = useRef(false)
// Focus and Cursor Positioning
useEffect(() => {
if (isFocused && !prevFocused.current && textRef.current) {
const el = textRef.current
if (document.activeElement !== el) {
el.focus({ preventScroll: true })
}
// Only move cursor to end once on initial focus
el.innerText = element.content
const range = document.createRange()
const sel = window.getSelection()
if (sel) {
range.selectNodeContents(el)
range.collapse(false)
sel.removeAllRanges()
sel.addRange(range)
}
}
prevFocused.current = isFocused
}, [isFocused, element.content])
// Height Synchronization
useEffect(() => {
if (textRef.current) {
const observer = new ResizeObserver(entries => {
const entry = entries[0]
if (entry) {
const newHeight = entry.contentRect.height
// Update store height if strictly different
if (Math.abs(newHeight - (element.height || 0)) > 0.5) {
updateElement(element.id, { height: newHeight })
}
}
})
observer.observe(textRef.current)
return () => observer.disconnect()
}
}, [element.id, element.height, updateElement])
return (
<div
ref={textRef}
contentEditable={isFocused}
suppressContentEditableWarning
className={cn(
'w-full h-auto outline-none break-words cursor-text select-text p-0 m-0',
!isFocused && 'pointer-events-none',
isEditing &&
'text-is-editing bg-transparent border-none shadow-none ring-0',
)}
draggable={!isEditing}
style={{
fontSize: element.fontSize,
fontWeight: element.fontWeight,
color: element.color,
fontFamily: element.fontFamily,
lineHeight: 1.2,
textAlign: element.textAlign,
fontStyle: element.fontStyle,
minHeight: '1em',
minWidth: '20px',
padding: 0,
margin: 0,
cursor: isFocused ? 'text' : 'default',
whiteSpace: element.width ? 'pre-wrap' : 'nowrap', // auto 宽度时不换行
paddingRight: isFocused ? '32px' : 0, // 预留光标位置
}}
onInput={e => {
const content = e.currentTarget.innerText
const height = e.currentTarget.offsetHeight
updateElement(element.id, { content, height })
}}
onPaste={e => {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
}}
onBlur={() => {
setFocusedElementId(null)
}}
>
{!isFocused && element.content}
</div>
)
}

View File

@@ -0,0 +1,130 @@
import { useMemo } from 'react'
import { useDominoStore } from './domino-hooks'
import { useTransform } from './use-transform'
import { getDominoDOM } from './dom-utils'
import { getElementWorldRect } from './math'
export interface AnchorRect {
left: number
top: number
width: number
height: number
right: number
bottom: number
centerX: number
centerY: number
}
/**
* Hook to get the screen-space coordinates of a Domino element.
* Useful for anchoring external UI components (like Modals or Tooltips) to canvas elements.
*/
export function useDominoAnchor(
elementId: string | null,
containerRef: React.RefObject<HTMLElement | null>,
screenSpace = false,
): AnchorRect | null {
const elements = useDominoStore(state => state.elements)
const placeholders = useDominoStore(state => state.placeholders)
const element = useDominoStore(state =>
elementId
? state.elements[elementId] || state.placeholders[elementId]
: null,
)
const viewport = useDominoStore(state => state.viewport)
const { worldToScreen } = useTransform()
return useMemo((): AnchorRect | null => {
if (!element || !containerRef.current) return null
// 1. [Better Calc] If screenSpace is enabled, try to use direct DOM measurement for precision
// This automatically handles native CSS transforms, scaling, and complex nesting.
if (screenSpace) {
const domEl = getDominoDOM(element.id)
if (domEl) {
const rect = domEl.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
right: rect.right,
bottom: rect.bottom,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
}
}
}
// 2. [Original Fallback] Manual math calculation (for non-screenSpace or when DOM is not yet ready)
// Calculate absolute world position using utility
const worldRect = getElementWorldRect(element, elements)
const worldX = worldRect.x
const worldY = worldRect.y
// Calculate the 4 corners in world space
const rad = ((element.rotation || 0) * Math.PI) / 180
// 当 width/height 为 0 时(表示 auto从 DOM 获取实际渲染尺寸
let elWidth = element.width
let elHeight = element.height
if (!elWidth || !elHeight) {
const domEl = getDominoDOM(element.id)
if (domEl) {
// Use offsetWidth/Height as they reflect layout size before CSS transforms (like rotation)
const scale = viewport.scale || 1
if (!elWidth) elWidth = domEl.offsetWidth / scale
if (!elHeight) elHeight = domEl.offsetHeight / scale
}
}
const cx = worldX + elWidth / 2
const cy = worldY + elHeight / 2
const corners = [
{ x: worldX, y: worldY }, // tl
{ x: worldX + elWidth, y: worldY }, // tr
{ x: worldX + elWidth, y: worldY + elHeight }, // br
{ x: worldX, y: worldY + elHeight }, // bl
]
const rotatedCorners = corners.map(p => {
// Rotate around center
const dx = p.x - cx
const dy = p.y - cy
const rx = dx * Math.cos(rad) - dy * Math.sin(rad)
const ry = dx * Math.sin(rad) + dy * Math.cos(rad)
return { x: rx + cx, y: ry + cy }
})
// Convert all corners to screen space
// If NOT screenSpace, worldToScreen returns relative to the canvas.
// If screenSpace, we want viewport-relative coordinates for fixed positioning.
const screenCorners = rotatedCorners.map(p => worldToScreen(p.x, p.y))
// Find the bounding box of the screen corners
const minX = Math.min(...screenCorners.map(p => p.x))
const minY = Math.min(...screenCorners.map(p => p.y))
const maxX = Math.max(...screenCorners.map(p => p.x))
const maxY = Math.max(...screenCorners.map(p => p.y))
return {
left: minX,
top: minY,
width: maxX - minX,
height: maxY - minY,
right: maxX,
bottom: maxY,
centerX: (minX + maxX) / 2,
centerY: (minY + maxY) / 2,
}
}, [
element,
elements,
placeholders,
viewport,
containerRef,
worldToScreen,
screenSpace,
])
}

View File

@@ -0,0 +1,13 @@
import type React from 'react'
import { useDominoDOMContext } from './domino-hooks'
/**
* Hook to get the Domino container DOM reference.
* Priority: explicitly provided ref > container ref from DominoDOMContext
*/
export function useDominoContainer(
explicitRef?: React.RefObject<HTMLElement | null>,
) {
const contextRef = useDominoDOMContext()
return explicitRef || contextRef
}

View File

@@ -0,0 +1,115 @@
import { useMemo } from 'react'
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
import { useDominoScrollIntoView } from './use-domino-scroll-into-view'
import { useDominoZoomToFit } from './use-domino-zoom-to-fit'
import type { Viewport, SceneElement } from './domino'
/**
* The Facade hook for Domino Canvas.
* Provides a unified object to control the canvas, inspired by useReactFlow.
*/
export function useDominoInstance(
explicitContainerRef?: React.RefObject<HTMLElement | null>,
) {
const store = useDominoStoreInstance()
const setViewport = useDominoStore(state => state.setViewport)
const addElement = useDominoStore(state => state.addElement)
const removeElement = useDominoStore(state => state.removeElement)
const updateElement = useDominoStore(state => state.updateElement)
const undo = useDominoStore(state => state.undo)
const redo = useDominoStore(state => state.redo)
const takeSnapshot = useDominoStore(state => state.takeSnapshot)
const moveElementUp = useDominoStore(state => state.moveElementUp)
const moveElementDown = useDominoStore(state => state.moveElementDown)
const moveElementToTop = useDominoStore(state => state.moveElementToTop)
const moveElementToBottom = useDominoStore(state => state.moveElementToBottom)
const scrollIntoView = useDominoScrollIntoView(explicitContainerRef)
const zoomToFit = useDominoZoomToFit(explicitContainerRef)
const instance = useMemo(
() => ({
/**
* Get current viewport state (raw access)
*/
getViewport: () => store.getState().viewport,
/**
* Set viewport state explicitly
*/
setViewport: (viewport: Partial<Viewport>) => setViewport(viewport),
/**
* Zoom the canvas to fit all elements
*/
zoomToFit,
/**
* Scroll a specific element into view
*/
scrollIntoView,
/**
* Manual zoom by multiplier at center
*/
zoomBy: (multiplier: number, cx: number, cy: number) => {
store.getState().zoomViewport(multiplier, cx, cy)
},
/**
* Undo the last change
*/
undo,
/**
* Redo the last undone change
*/
redo,
/**
* Manually take a history snapshot
*/
takeSnapshot,
/**
* Add a new element to the canvas
*/
addElement: (el: SceneElement) => addElement(el),
/**
* Remove an element by ID
*/
removeElement: (id: string) => removeElement(id),
/**
* Update an element's data
*/
updateElement: (id: string, updates: Partial<SceneElement>) =>
updateElement(id, updates),
/**
* Move an element up in the rendering order
*/
moveElementUp: (id: string) => moveElementUp(id),
/**
* Move an element down in the rendering order
*/
moveElementDown: (id: string) => moveElementDown(id),
/**
* Move an element to the very top
*/
moveElementToTop: (id: string) => moveElementToTop(id),
/**
* Move an element to the very bottom
*/
moveElementToBottom: (id: string) => moveElementToBottom(id),
}),
[
store,
setViewport,
zoomToFit,
scrollIntoView,
undo,
redo,
takeSnapshot,
addElement,
removeElement,
updateElement,
moveElementUp,
moveElementDown,
moveElementToTop,
moveElementToBottom,
],
)
return instance
}

View File

@@ -0,0 +1,57 @@
import { useCallback } from 'react'
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
import type { Padding, ScrollIntoViewOptions } from './domino'
import { useDominoContainer } from './use-domino-container'
import { calculateScrollIntoViewTransform } from './math'
/**
* Hook to scroll an element into view within the Domino canvas.
* (Internal hook, prefer using useDominoInstance)
*/
export function useDominoScrollIntoView(
explicitContainerRef?: React.RefObject<HTMLElement | null>,
) {
const store = useDominoStoreInstance()
const setViewport = useDominoStore(state => state.setViewport)
const containerRef = useDominoContainer(explicitContainerRef)
const scrollIntoView = useCallback(
(elementId: string, options?: ScrollIntoViewOptions) => {
const container = containerRef?.current
if (!container) return false
const { elements, viewport, padding: globalPadding } = store.getState()
const element = elements[elementId]
if (!element) return false
const finalPadding = {
top: options?.padding?.top ?? globalPadding.top ?? 0,
right: options?.padding?.right ?? globalPadding.right ?? 0,
bottom: options?.padding?.bottom ?? globalPadding.bottom ?? 0,
left: options?.padding?.left ?? globalPadding.left ?? 0,
} as Required<Padding>
const nextViewport = calculateScrollIntoViewTransform(
element,
viewport,
container.getBoundingClientRect(),
finalPadding,
{
force: options?.force,
block: options?.block,
inline: options?.inline,
targetScale: options?.scale,
},
)
if (nextViewport) {
setViewport(nextViewport)
return true
}
return false
},
[setViewport, containerRef, store],
)
return scrollIntoView
}

View File

@@ -0,0 +1,48 @@
import { useCallback } from 'react'
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
import type { Padding, ScrollIntoViewOptions, SceneElement } from './domino'
import { useDominoContainer } from './use-domino-container'
import { calculateZoomToFitTransform } from './math'
/**
* Hook to zoom the Domino canvas to fit all elements.
* (Internal hook, prefer using useDominoInstance)
*/
export function useDominoZoomToFit(
explicitContainerRef?: React.RefObject<HTMLElement | null>,
) {
const store = useDominoStoreInstance()
const setViewport = useDominoStore(state => state.setViewport)
const containerRef = useDominoContainer(explicitContainerRef)
const zoomToFit = useCallback(
(options?: Omit<ScrollIntoViewOptions, 'force' | 'scale'>) => {
const container = containerRef?.current
if (!container) return
const { elements, padding: globalPadding } = store.getState()
const elementList = Object.values(elements) as SceneElement[]
if (elementList.length === 0) return
const finalPadding = {
top: options?.padding?.top ?? globalPadding.top ?? 0,
right: options?.padding?.right ?? globalPadding.right ?? 0,
bottom: options?.padding?.bottom ?? globalPadding.bottom ?? 0,
left: options?.padding?.left ?? globalPadding.left ?? 0,
} as Required<Padding>
const nextViewport = calculateZoomToFitTransform(
elementList,
container.getBoundingClientRect(),
finalPadding,
)
if (nextViewport) {
setViewport(nextViewport)
}
},
[setViewport, containerRef, store],
)
return zoomToFit
}

View File

@@ -0,0 +1,115 @@
import { useWheel, usePinch } from '@use-gesture/react'
import { useDominoStoreInstance } from './domino-hooks'
const isMac =
typeof navigator !== 'undefined' &&
/Mac|iPhone|iPod|iPad/.test(navigator.userAgent)
export function useGestures(
containerRef: React.RefObject<HTMLElement | null>,
options: {
minScale?: number
maxScale?: number
} = {},
) {
const { minScale = 0.1, maxScale = 20 } = options
const store = useDominoStoreInstance()
const { moveViewport, zoomViewport, setViewport } = store.getState()
// 更新 DOM 叠加层的状态类名(用于鼠标样式和业务联动)
const updateDOMOverlay = (type: 'panning' | 'zooming' | 'none') => {
const container = containerRef.current
if (!container) return
container.classList.toggle('is-panning', type === 'panning')
container.classList.toggle('is-zooming', type === 'zooming')
}
useWheel(
({ delta: [dx, dy], event, ctrlKey, active }) => {
// 冲突检测:如果滚轮发生在可滚动 UI 内部,不移动画布
if (!ctrlKey) {
const target = event.target as HTMLElement
const scrollable = target?.closest(
'.overflow-y-auto, .overflow-y-scroll, .overflow-auto, [data-radix-scroll-area-viewport], .scrollbar-container',
)
if (scrollable && scrollable.scrollHeight > scrollable.clientHeight) {
return
}
}
if (active) {
updateDOMOverlay(ctrlKey ? 'zooming' : 'panning')
} else {
updateDOMOverlay('none')
}
if (ctrlKey) {
event.preventDefault()
const container = containerRef.current
if (!container) return
const rect = container.getBoundingClientRect()
const cx = (event as WheelEvent).clientX - rect.left
const cy = (event as WheelEvent).clientY - rect.top
const multiplier = Math.min(
1.25,
Math.max(0.8, isMac ? 0.99 ** dy : 0.999 ** dy),
)
zoomViewport(multiplier, cx, cy, minScale, maxScale)
return
}
event.preventDefault()
moveViewport(-dx, -dy)
},
{
target: containerRef,
eventOptions: { passive: false },
},
)
usePinch(
({ origin: [ox, oy], first, movement: [ms], memo, active }) => {
if (active) {
updateDOMOverlay('zooming')
} else {
updateDOMOverlay('none')
}
const container = containerRef.current
if (!container) return
if (first) {
const { viewport } = store.getState()
const rect = container.getBoundingClientRect()
const cx = ox - rect.left
const cy = oy - rect.top
return {
startScale: viewport.scale,
anchorWx: (cx - viewport.x) / viewport.scale,
anchorWy: (cy - viewport.y) / viewport.scale,
cx,
cy,
}
}
const { cx, cy } = memo
const sensitivity = 1.5
const adjustedMs = 1 + (ms - 1) * sensitivity
const targetScale = memo.startScale * adjustedMs
const newScale = Math.max(minScale, Math.min(maxScale, targetScale))
setViewport({
x: cx - memo.anchorWx * newScale,
y: cy - memo.anchorWy * newScale,
scale: newScale,
})
return memo
},
{
target: containerRef,
eventOptions: { passive: false },
},
)
}

View File

@@ -0,0 +1,839 @@
import { useCallback, useRef, useEffect } from 'react'
import { useDominoStore, useDominoStoreInstance } from './domino-hooks'
import type { DominoEvents, SceneElement, ArtboardElement } from './domino'
import { DOMINO_EL_PREFIX } from './constants'
import { useTransform } from './use-transform'
import { getElementWorldRect } from './math'
import { calculateSnap } from './interactions/snapping'
import { calculateResize } from './interactions/resizing'
function isTargetInputOrEditable(target: HTMLElement | null) {
if (!target) return false
// 1. 标准输入元素
const tagName = target.tagName.toLowerCase()
if (tagName === 'input' || tagName === 'textarea') return true
// 2. 可编辑元素
return target.isContentEditable
}
type InteractionStatus =
| 'none'
| 'idle'
| 'panning'
| 'selecting'
| 'dragging'
| 'resizing'
| 'rotating'
interface InteractionOptions extends DominoEvents {}
export function useInteractions(
containerRef: React.RefObject<HTMLElement | null>,
options: InteractionOptions,
) {
const { onClick, onElementClick, onTransformEnd } = options
const store = useDominoStoreInstance()
const statusRef = useRef<InteractionStatus>('none')
const startPos = useRef({ x: 0, y: 0 }) // Screen coordinates
const lastPos = useRef({ x: 0, y: 0 }) // Screen coordinates
const { screenToWorld } = useTransform()
const moveViewport = useDominoStore(state => state.moveViewport)
const setSelectionBox = useDominoStore(state => state.setSelectionBox)
const setSnapLines = useDominoStore(state => state.setSnapLines)
const setFocusedElementId = useDominoStore(state => state.setFocusedElementId)
const setSelectedIds = useDominoStore(state => state.setSelectedIds)
/**
* 直接通过 DOM API 更新容器状态,避免 React 频繁重绘
*/
const updateDOMOverlay = useCallback(
(gestureStatus: InteractionStatus = 'none', updateCursor = true) => {
const container = containerRef.current
if (!container) return
const state = store.getState()
const mainStatus = statusRef.current
// 更新类名:支持主状态和手势状态叠加
const statuses: InteractionStatus[] = [
'panning',
'selecting',
'dragging',
'resizing',
'rotating',
]
statuses.forEach(s => {
const isActive = s === mainStatus || s === gestureStatus
container.classList.toggle(`is-${s}`, isActive)
})
// 更新光标:仅由主状态 (PointerDown) 和工具模式 (Mode) 决定
if (updateCursor) {
if (mainStatus === 'panning') {
container.style.cursor = 'grabbing'
} else if (mainStatus === 'none') {
// 恢复到工具模式对应的光标
container.style.cursor =
state.mode === 'pan' ? 'grab' : 'url(/arrow-cursor.svg), auto'
}
}
},
[containerRef, store],
)
const setStatus = useCallback(
(newStatus: InteractionStatus, updateCursor = true) => {
statusRef.current = newStatus
updateDOMOverlay('none', updateCursor)
},
[updateDOMOverlay],
)
// 监听交互模式切换(手动订阅以保持 updateDOMOverlay 恒定)
useEffect(() => {
// 初始执行一次
updateDOMOverlay('none', true)
// 监听 store 中的模式变化
let lastMode = store.getState().mode
const unsubscribe = store.subscribe(state => {
if (state.mode !== lastMode) {
lastMode = state.mode
updateDOMOverlay('none', true)
}
})
return unsubscribe
}, [updateDOMOverlay, store])
const resizeHandle = useRef<string | null>(null) // 'nw', 'ne', 'sw', 'se', 'ml', 'mr'
const startRotation = useRef(0)
const startAngle = useRef(0)
const dragStartElements = useRef<
Record<
string,
{
x: number
y: number
width: number
height: number
fontSize?: number
originalParentId?: string
originalIndex?: number
}
>
>({})
const isSnappedToOriginal = useRef(false)
const hasMoved = useRef(false)
const onDoubleClickCapture = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (isTargetInputOrEditable(target)) return
const state = store.getState()
const { elements: currentElements, selectedIds, focusedElementId } = state
const elementId = focusedElementId || selectedIds[0]
const element = elementId ? currentElements[elementId] : null
if (elementId && element?.type === 'text') {
setFocusedElementId(elementId)
setSelectedIds(selectedIds.filter(id => id !== elementId))
}
}, [])
const onPointerDown = useCallback(
(e: React.PointerEvent) => {
// Ignore right click for interactions to prevent conflict with browser context menu
if (e.button === 2) return
const target = e.target as HTMLElement
if (isTargetInputOrEditable(target)) return
const state = store.getState()
const {
elements: currentElements,
placeholders: currentPlaceholders,
selectedIds: currentSelectedIds,
mode: currentMode,
readOnly: isReadOnly,
} = state
const elementEl = target.closest('.domino-element') as HTMLElement | null
const rawId = elementEl?.id
const elementId = rawId?.startsWith(DOMINO_EL_PREFIX)
? rawId.slice(DOMINO_EL_PREFIX.length)
: rawId
const element = elementId
? currentElements[elementId] || currentPlaceholders[elementId]
: null
const isElement = !!(element && element.selectable !== false)
const handleEl = target.closest(
'.domino-resize-handle',
) as HTMLElement | null
const rotateEl = target.closest(
'.domino-rotate-handle',
) as HTMLElement | null
const multiSelectEl = target.closest(
'.domino-multi-select-area',
) as HTMLElement | null
const isBackground = target === containerRef.current
// Determine interaction type based on target and current state
let interactionType: InteractionStatus = 'idle'
if (isReadOnly) {
// If read-only, only allow panning or element focus/click
if (
e.button === 1 ||
(e.button === 0 && e.altKey) ||
currentMode === 'pan'
) {
interactionType = 'panning'
} else if (isElement && elementId) {
// Allow focusing/clicking elements even if read-only
state.setFocusedElementId(elementId)
if (!e.shiftKey) {
state.setSelectedIds([elementId])
}
// Stay in 'idle' for click detection in onPointerUp
interactionType = 'idle'
} else {
// No interaction for other cases in read-only mode
return
}
} else {
// Not read-only
if (
e.button === 1 ||
(e.button === 0 && e.altKey) ||
currentMode === 'pan'
) {
interactionType = 'panning'
} else if (isElement && elementId) {
const id = elementId
const uiState = state.elementUIStates[id]
// CRITICAL: If the element is already focused (editing text),
// we MUST BALE OUT to allow native cursor movement and avoid clearing focus.
if (
id === state.focusedElementId &&
element.type === 'text' &&
!store.getState().selectedIds.includes(id)
) {
setStatus('none')
return
}
if (uiState?.status === 'readonly') {
state.setFocusedElementId(id)
if (!e.shiftKey) {
state.setSelectedIds([id])
}
// Stay in 'idle' for click detection in onPointerUp
interactionType = 'idle'
} else if (element.type === 'placeholder') {
// Placeholder: Focusable but not selectable
state.setFocusedElementId(id)
if (!e.shiftKey) {
state.setSelectedIds([])
}
// Stay in 'idle' for click detection in onPointerUp
interactionType = 'idle'
} else {
// Element interaction: clicking selects it (shows dots)
state.setFocusedElementId(null)
let targetIds = currentSelectedIds
if (e.shiftKey) {
if (currentSelectedIds.includes(id)) {
// Toggle off
targetIds = currentSelectedIds.filter(sid => sid !== id)
} else {
// Toggle on
targetIds = [...currentSelectedIds, id]
}
state.setSelectedIds(targetIds)
} else {
if (!currentSelectedIds.includes(id)) {
// New single selection
targetIds = [id]
state.setSelectedIds(targetIds)
}
// If already selected, we keep targetIds = selectedIds to allow group drag
}
// Only start dragging if the clicked element is part of the final selection
if (targetIds.includes(id)) {
const draggableIds = targetIds.filter(tid => {
const el = currentElements[tid] || currentPlaceholders[tid]
return el?.draggable !== false
})
if (draggableIds.length === 0) {
setStatus('none')
return
}
// Store initial positions for all elements to be moved
const startPositions: Record<
string,
{
x: number
y: number
width: number
height: number
originalParentId?: string
originalIndex?: number
}
> = {}
draggableIds.forEach(did => {
const el = currentElements[did] || currentPlaceholders[did]
if (el) {
startPositions[did] = {
x: el.x,
y: el.y,
width: el.width,
height: el.height,
originalParentId: el.parentId,
originalIndex: el.parentId
? (
currentElements[el.parentId] as ArtboardElement
)?.childrenIds?.indexOf(did)
: state.elementOrder.indexOf(did),
}
}
})
dragStartElements.current = startPositions
interactionType = 'dragging'
} else {
interactionType = 'none'
}
}
} else if (handleEl) {
// Handle resizing
interactionType = 'resizing'
resizeHandle.current = handleEl.dataset.handle || ''
const id = currentSelectedIds[0]
const el = currentElements[id] || currentPlaceholders[id]
if (el) {
dragStartElements.current = {
[id]: {
x: el.x,
y: el.y,
width: el.width,
height: el.height,
fontSize: el.type === 'text' ? el.fontSize : undefined,
},
}
isSnappedToOriginal.current = false
}
} else if (rotateEl) {
// Handle rotating
interactionType = 'rotating'
const id = currentSelectedIds[0]
const el = currentElements[id] || currentPlaceholders[id]
if (el) {
startRotation.current = el.rotation || 0
const rect = containerRef.current?.getBoundingClientRect()
const worldPos = screenToWorld(e.clientX, e.clientY, rect)
const worldRect = getElementWorldRect(el, currentElements)
const centerX = worldRect.x + worldRect.width / 2
const centerY = worldRect.y + worldRect.height / 2
startAngle.current = Math.atan2(
worldPos.y - centerY,
worldPos.x - centerX,
)
}
} else if (multiSelectEl && currentSelectedIds.length > 1) {
// Handle dragging for multiple selected elements
const draggableIds = currentSelectedIds.filter(id => {
const el = currentElements[id] || currentPlaceholders[id]
return el?.draggable !== false
})
if (draggableIds.length > 0) {
const startPositions: Record<
string,
{
x: number
y: number
width: number
height: number
originalParentId?: string
originalIndex?: number
}
> = {}
draggableIds.forEach(id => {
const el = currentElements[id] || currentPlaceholders[id]
if (el) {
startPositions[id] = {
x: el.x,
y: el.y,
width: el.width,
height: el.height,
originalParentId: el.parentId,
originalIndex: el.parentId
? (
currentElements[el.parentId] as ArtboardElement
)?.childrenIds?.indexOf(id)
: state.elementOrder.indexOf(id),
}
}
})
dragStartElements.current = startPositions
interactionType = 'dragging'
} else {
interactionType = 'none'
}
} else if (isBackground) {
// Background interaction: only selection, no panning by default
state.setFocusedElementId(null)
if (!e.shiftKey) {
state.setSelectedIds([])
}
interactionType = 'selecting'
} else {
// If not an element, not background, and not a special UI handle, then bail out.
// This covers clicks on overlays that are not part of the canvas interaction.
return
}
}
startPos.current = { x: e.clientX, y: e.clientY }
lastPos.current = { x: e.clientX, y: e.clientY }
hasMoved.current = false
if (containerRef.current) {
containerRef.current.setPointerCapture(e.pointerId)
containerRef.current.focus()
}
setStatus(interactionType)
},
[setStatus, containerRef, screenToWorld, store],
)
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
const status = statusRef.current
if (status === 'none') return
const dx = e.clientX - lastPos.current.x
const dy = e.clientY - lastPos.current.y
// 距离阈值检测:防止微小手抖触发误操作
const dist = Math.sqrt(
(e.clientX - startPos.current.x) ** 2 +
(e.clientY - startPos.current.y) ** 2,
)
const THRESHOLD = 3
const isThresholdMet = dist >= THRESHOLD || hasMoved.current
// 仅针对拖拽、缩放、旋转、框选应用阈值平移panning保持即时响应
if (status !== 'panning' && status !== 'idle' && !isThresholdMet) {
return
}
e.preventDefault()
const state = store.getState()
const { scale } = state.viewport
const {
selectedIds: currentSelectedIds,
elements: currentElements,
placeholders: currentPlaceholders,
} = state
if (status === 'panning') {
moveViewport(dx, dy)
lastPos.current = { x: e.clientX, y: e.clientY }
} else if (status === 'dragging') {
const totalDx = (e.clientX - startPos.current.x) / scale
const totalDy = (e.clientY - startPos.current.y) / scale
// Only snap if not holding Command (Mac) or Control (Windows/Linux)
const shouldSnap = !e.metaKey && !e.ctrlKey
const { snapOffsetX, snapOffsetY, finalSnapLines } = calculateSnap(
currentSelectedIds,
dragStartElements.current,
currentElements,
currentPlaceholders,
totalDx,
totalDy,
scale,
shouldSnap,
)
state.setSnapLines(finalSnapLines)
// 脱离/吸附画板逻辑
const rect = containerRef.current?.getBoundingClientRect()
const mouseWorld = screenToWorld(e.clientX, e.clientY, rect)
// 查找当前鼠标下方的画板(排除当前选中的元素本身及其子树,避免循环嵌套)
const artboardUnderMouse = Object.values(currentElements)
.filter(
el => el.type === 'artboard' && !currentSelectedIds.includes(el.id),
)
.reverse()
.find(ab => {
const abWorld = getElementWorldRect(ab, currentElements)
return (
mouseWorld.x >= abWorld.x &&
mouseWorld.x <= abWorld.x + abWorld.width &&
mouseWorld.y >= abWorld.y &&
mouseWorld.y <= abWorld.y + abWorld.height
)
})
// 按照当前的视觉层级顺序(从小到大)进行排序,避免在批量移动到同一个父级时层级被打乱
const sortedSelectedIds = [...currentSelectedIds].sort((a, b) => {
const elA = currentElements[a]
const elB = currentElements[b]
if (elA?.parentId === elB?.parentId) {
const siblings = elA?.parentId
? (currentElements[elA.parentId] as ArtboardElement)?.childrenIds
: state.elementOrder
return siblings.indexOf(a) - siblings.indexOf(b)
}
return 0
})
sortedSelectedIds.forEach(id => {
const el = currentElements[id]
if (!el || el.type === 'artboard') return
// 仅处理选中项中的顶层元素
let isTopSelected = true
let pId = el.parentId
while (pId) {
if (currentSelectedIds.includes(pId)) {
isTopSelected = false
break
}
pId = currentElements[pId]?.parentId || ''
}
if (!isTopSelected) return
// 获取元素当前所属的顶层画板
let currentArtboard: SceneElement | null = null
let curr = el
while (curr.parentId) {
const p = currentElements[curr.parentId]
if (!p) break
if (p.type === 'artboard') {
currentArtboard = p
break
}
curr = p
}
const targetArtboardId = artboardUnderMouse?.id || null
// 情况 1: 如果当前在某个画板内
if (currentArtboard) {
const cbWorld = getElementWorldRect(
currentArtboard,
currentElements,
)
const isInsideCurrent =
mouseWorld.x >= cbWorld.x &&
mouseWorld.x <= cbWorld.x + cbWorld.width &&
mouseWorld.y >= cbWorld.y &&
mouseWorld.y <= cbWorld.y + cbWorld.height
// 如果鼠标移出了当前所在的画板,则执行切换(移入新画板或移回根级)
if (!isInsideCurrent) {
const worldRect = getElementWorldRect(el, currentElements)
const initialWorldX = worldRect.x - totalDx
const initialWorldY = worldRect.y - totalDy
const startMeta = dragStartElements.current[id]
const targetIndex =
targetArtboardId === startMeta?.originalParentId
? startMeta.originalIndex
: undefined
state.moveElementToParent(id, targetArtboardId, targetIndex)
// 关键:同步更新拖拽起始点引用,使其在新的坐标系下保持一致
if (dragStartElements.current[id]) {
let relX = initialWorldX
let relY = initialWorldY
if (targetArtboardId && artboardUnderMouse) {
const parentWorld = getElementWorldRect(
artboardUnderMouse,
currentElements,
)
relX -= parentWorld.x
relY -= parentWorld.y
}
dragStartElements.current[id].x = relX
dragStartElements.current[id].y = relY
}
}
} else if (targetArtboardId && el.parentId !== targetArtboardId) {
// 情况 2: 如果当前不在画板内,且鼠标进入了一个画板
const worldRect = getElementWorldRect(el, currentElements)
const initialWorldX = worldRect.x - totalDx
const initialWorldY = worldRect.y - totalDy
const startMeta = dragStartElements.current[id]
const targetIndex =
targetArtboardId === startMeta?.originalParentId
? startMeta.originalIndex
: undefined
state.moveElementToParent(id, targetArtboardId, targetIndex)
if (dragStartElements.current[id]) {
let relX = initialWorldX
let relY = initialWorldY
if (artboardUnderMouse) {
const parentWorld = getElementWorldRect(
artboardUnderMouse,
currentElements,
)
relX -= parentWorld.x
relY -= parentWorld.y
}
dragStartElements.current[id].x = relX
dragStartElements.current[id].y = relY
}
}
})
// Apply movement with snap offset
currentSelectedIds.forEach(id => {
const start = dragStartElements.current[id]
if (start) {
// Record history before first movement
if (!hasMoved.current && currentElements[id]) {
state.takeSnapshot()
hasMoved.current = true
}
state.updateElement(id, {
x: start.x + totalDx + snapOffsetX,
y: start.y + totalDy + snapOffsetY,
})
}
})
} else if (status === 'resizing' && currentSelectedIds.length > 0) {
const id = currentSelectedIds[0]
const startEl = dragStartElements.current[id]
if (!startEl) return
const handle = resizeHandle.current
if (!handle) return
const el = currentElements[id] || currentPlaceholders[id]
if (!el) return
const totalWdx = (e.clientX - startPos.current.x) / scale
const totalWdy = (e.clientY - startPos.current.y) / scale
const { updates, isSnappedToOriginal: snapped } = calculateResize(
id,
startEl,
el,
handle,
totalWdx,
totalWdy,
scale,
e.shiftKey,
e.altKey,
)
isSnappedToOriginal.current = snapped
// Record history before first movement
if (!hasMoved.current && currentElements[id]) {
state.takeSnapshot()
hasMoved.current = true
}
state.updateElement(id, updates)
} else if (status === 'rotating' && currentSelectedIds.length > 0) {
const id = currentSelectedIds[0]
const el = currentElements[id] || currentPlaceholders[id]
if (!el) return
const rect = containerRef.current?.getBoundingClientRect()
const worldPos = screenToWorld(e.clientX, e.clientY, rect)
const worldRect = getElementWorldRect(el, currentElements)
const centerX = worldRect.x + worldRect.width / 2
const centerY = worldRect.y + worldRect.height / 2
const currentAngle = Math.atan2(
worldPos.y - centerY,
worldPos.x - centerX,
)
const angleDiff = ((currentAngle - startAngle.current) * 180) / Math.PI
let newRotation = startRotation.current + angleDiff
// Snap to 15 degree increments if Shift is held
if (e.shiftKey) {
newRotation = Math.round(newRotation / 15) * 15
} else {
// Magnetic snapping to cardinal angles
const snapAngles = [0, 45, 90, 135, 180, 225, 270, 315]
const snapThreshold = 3
const normalized = ((newRotation % 360) + 360) % 360
for (const target of snapAngles) {
let diff = normalized - target
if (diff > 180) diff -= 360
if (diff < -180) diff += 360
if (Math.abs(diff) < snapThreshold) {
newRotation -= diff
break
}
}
}
// Record history before first movement
if (!hasMoved.current && currentElements[id]) {
state.takeSnapshot()
hasMoved.current = true
}
state.updateElement(id, { rotation: newRotation })
} else if (status === 'selecting') {
const rect = containerRef.current?.getBoundingClientRect()
const currentWorld = screenToWorld(e.clientX, e.clientY, rect)
const startWorld = screenToWorld(
startPos.current.x,
startPos.current.y,
rect,
)
const x = Math.min(startWorld.x, currentWorld.x)
const y = Math.min(startWorld.y, currentWorld.y)
const width = Math.abs(startWorld.x - currentWorld.x)
const height = Math.abs(startWorld.y - currentWorld.y)
const box = { x, y, width, height }
state.setSelectionBox(box)
state.setFocusedElementId(null)
// Update selection in real-time
const newSelectedIds = Object.values(currentElements)
.filter(el => {
if (
el.type === 'artboard' ||
el.type === 'placeholder' ||
el.selectable === false
)
return false
const worldRect = getElementWorldRect(el, currentElements)
const elXMax = worldRect.x + worldRect.width
const elYMax = worldRect.y + worldRect.height
const boxXMax = x + width
const boxYMax = y + height
return !(
elXMax < x ||
worldRect.x > boxXMax ||
elYMax < y ||
worldRect.y > boxYMax
)
})
.map(el => el.id)
state.setSelectedIds(newSelectedIds)
// Manual hover detection during selection (pointer is captured)
const hovered = Object.values(currentElements)
.reverse() // Check from top to bottom
.find(el => {
if (el.type === 'artboard' || el.selectable === false) return false
const worldRect = getElementWorldRect(el, currentElements)
return (
currentWorld.x >= worldRect.x &&
currentWorld.x <= worldRect.x + worldRect.width &&
currentWorld.y >= worldRect.y &&
currentWorld.y <= worldRect.y + worldRect.height
)
})
state.setHoveredElementId(hovered ? hovered.id : null)
}
lastPos.current = { x: e.clientX, y: e.clientY }
},
[moveViewport, screenToWorld, store, containerRef],
)
const onPointerUp = useCallback(
(e: React.PointerEvent) => {
// ONLY handle if we started an interaction in onPointerDown
if (statusRef.current === 'none') return
const state = store.getState()
const dist = Math.sqrt(
(e.clientX - startPos.current.x) ** 2 +
(e.clientY - startPos.current.y) ** 2,
)
// Click detection: distance moved is small
if (dist < 5) {
const target = e.target as HTMLElement
const elementEl = target.closest('.domino-element')
const id = elementEl?.id
onClick?.(e, id)
if (id) {
const element = state.elements[id] || state.placeholders[id]
if (element) {
onElementClick?.(id, element)
}
}
}
// Transform end detection
if (hasMoved.current) {
onTransformEnd?.(state.selectedIds)
}
setStatus('none')
hasMoved.current = false
setSelectionBox(null)
setSnapLines(null)
if (containerRef.current) {
containerRef.current.releasePointerCapture(e.pointerId)
}
},
[
containerRef,
setSnapLines,
setSelectionBox,
setStatus,
onClick,
onElementClick,
onTransformEnd,
store,
],
)
const onPointerCancel = useCallback(
(e: React.PointerEvent) => {
setStatus('none')
setSelectionBox(null)
setSnapLines(null)
if (containerRef.current) {
containerRef.current.releasePointerCapture(e.pointerId)
}
},
[setStatus, setSelectionBox, setSnapLines, containerRef],
)
return {
onDoubleClickCapture,
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
}
}

View File

@@ -0,0 +1,33 @@
import { useDominoStore } from './domino-hooks'
export function useTransform() {
const viewport = useDominoStore(state => state.viewport)
const screenToWorld = (
clientX: number,
clientY: number,
containerRect?: DOMRect,
) => {
const x = containerRect ? clientX - containerRect.left : clientX
const y = containerRect ? clientY - containerRect.top : clientY
return {
x: (x - viewport.x) / viewport.scale,
y: (y - viewport.y) / viewport.scale,
}
}
const worldToScreen = (
worldX: number,
worldY: number,
containerRect?: DOMRect,
) => {
const x = worldX * viewport.scale + viewport.x
const y = worldY * viewport.scale + viewport.y
return {
x: containerRect ? x + containerRect.left : x,
y: containerRect ? y + containerRect.top : y,
}
}
return { screenToWorld, worldToScreen }
}

View File

@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react'
import type { ArtboardElement } from '../canvas'
import { useDominoStore } from '../canvas'
import { LocalIcon } from '../ui/local-icon'
interface ArtboardActionsToolbarProps {
element: ArtboardElement
onAction?: (actionId: string) => void
}
const TOOLBAR_OFFSET_Y = -20
export const ArtboardActionsToolbar: React.FC<ArtboardActionsToolbarProps> = ({
element,
onAction,
}) => {
const viewport = useDominoStore(state => state.viewport)
const { x, y, width, height, rotation = 0 } = element
const { x: vx, y: vy, scale: vs } = viewport
const updateElement = useDominoStore(state => state.updateElement)
const [w, setW] = useState<number | string>(Math.round(width))
const [h, setH] = useState<number | string>(Math.round(height))
const lastElementIdRef = React.useRef(element.id)
useEffect(() => {
if (lastElementIdRef.current !== element.id) {
setW(Math.round(width))
setH(Math.round(height))
lastElementIdRef.current = element.id
}
}, [element.id, width, height])
const handleWidthChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value)
if (!isNaN(val)) {
setW(val)
updateElement(element.id, { width: val })
} else {
setW(e.target.value)
}
}
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value)
if (!isNaN(val)) {
setH(val)
updateElement(element.id, { height: val })
} else {
setH(e.target.value)
}
}
return (
<div
className='domino-toolbar absolute pointer-events-none z-[10]'
style={{
left: x * vs + vx,
top: (y + TOOLBAR_OFFSET_Y) * vs + vy,
width: width * vs,
height: height * vs,
transform: `rotate(${rotation}deg)`,
}}
>
<div
className='editor-floating-panel-soft absolute left-1/2 flex items-center gap-[8px] rounded-full p-[6px] pointer-events-auto'
style={{
bottom: '100%',
marginBottom: '12px',
transform: 'translate(-50%, 0)',
}}
onClick={e => e.stopPropagation()}
onPointerDown={e => e.stopPropagation()}
>
<div className='editor-toolbar-chip flex h-[32px] items-center gap-[4px] rounded-full px-[12px] focus-within:ring-2 focus-within:ring-ring/60'>
<span className='select-none text-[12px] font-medium text-[var(--editor-text-muted)]'>
W
</span>
<input
type='number'
value={w}
onChange={handleWidthChange}
className='w-[44px] bg-transparent text-[12px] text-foreground outline-none tabular-nums [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none'
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur()
}}
/>
</div>
<div className='editor-toolbar-chip flex h-[32px] items-center gap-[4px] rounded-full px-[12px] focus-within:ring-2 focus-within:ring-ring/60'>
<span className='select-none text-[12px] font-medium text-[var(--editor-text-muted)]'>
H
</span>
<input
type='number'
value={h}
onChange={handleHeightChange}
className='w-[44px] bg-transparent text-[12px] text-foreground outline-none tabular-nums [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none'
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur()
}}
/>
</div>
<div
className='ml-4px flex h-[32px] w-[32px] cursor-pointer items-center justify-center rounded-full text-foreground transition-colors hover:bg-accent'
onClick={() => onAction?.('download')}
>
<LocalIcon
name='download'
className='h-[18px] w-[18px]'
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,142 @@
import React, { memo } from 'react'
import { cn } from '@/utils/cn'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from '../ui/tooltip'
import { IconButton } from '../ui/icon'
import { useDominoStore } from '../canvas'
export interface BottomToolbarProps {
className?: string
onAddArtboard?: () => void
onAddImage?: () => void
onAddText?: () => void
}
const BottomToolbar: React.FC<BottomToolbarProps> = ({
className,
onAddArtboard,
onAddImage,
onAddText,
}) => {
const mode = useDominoStore(s => s.mode)
const setMode = useDominoStore(s => s.setMode)
const undo = useDominoStore(s => s.undo)
const redo = useDominoStore(s => s.redo)
const past = useDominoStore(s => s.past)
const future = useDominoStore(s => s.future)
const isPanMode = mode === 'pan'
const canUndo = past.length > 0
const canRedo = future.length > 0
const tools = [
{
id: 'select-pan',
icon: isPanMode ? 'pan-mode' : 'select-mode',
label: isPanMode ? '平移' : '选择',
isMode: true,
onClick: () => setMode(isPanMode ? 'select' : 'pan'),
},
{
id: 'artboard',
icon: 'artboard-mode',
label: '智能画板',
onClick: onAddArtboard,
},
{
id: 'image',
icon: 'image-mode',
label: '上传图片',
onClick: onAddImage,
},
{
id: 'text',
icon: 'text-mode',
label: '文字',
onClick: onAddText,
},
]
return (
<div
className={cn(
'domino-toolbar absolute bottom-[40px] left-1/2 -translate-x-1/2 z-[10] pointer-events-none',
'editor-floating-panel-soft flex items-center gap-[2px] rounded-[100px] p-[4px]',
className,
)}
>
<div className='flex items-center gap-[2px] pointer-events-auto'>
{tools.map(tool => (
<TooltipProvider key={tool.id} delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon={tool.icon}
size='w-[28px] h-[28px]'
iconSize='w-[16px] h-[16px]'
className={cn(
'rounded-full',
tool.isMode
? 'editor-toolbar-chip-active'
: 'text-muted-foreground hover:bg-accent',
)}
onClick={tool.onClick}
/>
</TooltipTrigger>
<TooltipContent>{tool.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<div className='editor-toolbar-divider mx-[2px] h-[16px] w-[0.5px]' />
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon='undo'
size='w-[28px] h-[28px]'
iconSize='w-[16px] h-[16px]'
className={cn(
'rounded-full text-muted-foreground',
canUndo
? 'hover:bg-accent'
: 'opacity-30 cursor-not-allowed',
)}
onClick={undo}
/>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<IconButton
icon='redo'
size='w-[28px] h-[28px]'
iconSize='w-[16px] h-[16px]'
className={cn(
'rounded-full text-muted-foreground',
canRedo
? 'hover:bg-accent'
: 'opacity-30 cursor-not-allowed',
)}
onClick={redo}
/>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
)
}
export default memo(BottomToolbar)

View File

@@ -0,0 +1,396 @@
import React, { memo, useState, useCallback, useMemo } from 'react'
import type {
ImageOCRResponse,
RecognizedImageElement,
} from '../../service/type'
import { cn } from '@/utils/cn'
import {
useDominoStoreInstance,
useDominoInstance,
useDominoElements,
useDominoSelectedIds,
useDominoElementUIStates,
useDominoSelectionBounds,
getDominoDOM,
DominoCanvas,
DominoAnchor,
type ImageElement,
type SceneElement,
type TextElement,
} from '../canvas'
import { useDownLoadImageGroup } from '../../hooks/use-download-image-group'
import { useElementActions } from '../../hooks/use-element-actions'
import { useImageEditActions } from '../../hooks/use-image-edit-actions'
import { useLayout } from '../../hooks/use-layout'
import { useShortcuts } from '../../hooks/use-shortcuts'
import { usePasteHandler } from '../../hooks/use-paste-handler'
import { ArtboardActionsToolbar } from './artboard-actions-toolbar'
import ImageTextEditor from './image-text-editor'
import ImageRedrawEditor, { type RedrawState } from './image-redraw-editor'
import TopBar from './top-bar'
import BottomToolbar from './bottom-toolbar'
import SelectionOverlay from './selection-overlay'
import { ElementMetadata } from './element-metadata'
import { Loading } from './loading'
export interface DomiBoardProps {
className?: string
style?: React.CSSProperties
/**
* 列最大宽度
*/
colMaxWidth?: number
/**
* 列间距
*/
colGap?: number
/**
* 行间距
*/
rowGap?: number
/**
* 返回文件列表回调,不传不显示返回按钮
*/
onBack?: (e: React.MouseEvent) => void
/**
* 关闭图片编辑器回调,不传不显示关闭按钮
*/
onClose?: () => void
/**
* 是否只读
*/
readOnly?: boolean
/**
* 元素数据
*/
elements?: Record<string, SceneElement>
/**
* 元素顺序
*/
elementOrder?: string[]
/**
* 是否加载中
*/
loading?: boolean
/**
* 任务ID/会话ID
*/
taskId?: string
/**
* 是否展开
*/
expand?: boolean
/**
* 切换展开状态回调
*/
onToggleExpand?: (e: React.MouseEvent) => void
}
const DomiBoard = memo((props: DomiBoardProps) => {
const {
className,
style,
colMaxWidth = 7000,
colGap = 40,
rowGap = 120,
onBack,
onClose,
readOnly = false,
elements: externalElements,
elementOrder: externalElementOrder,
loading: externalLoading,
expand,
onToggleExpand,
} = props
const rootRef = React.useRef<HTMLDivElement>(null)
// Semantic Read Hooks
const elements = useDominoElements()
const selectedIds = useDominoSelectedIds()
const selectionBounds = useDominoSelectionBounds()
const elementUIStates = useDominoElementUIStates()
// Facade Instance Hook
const instance = useDominoInstance()
// Internal store access for low-level actions
const store = useDominoStoreInstance()
const [editingDimensionId, setEditingDimensionId] = useState<string | null>(
null,
)
const [ocrData, setOcrData] = useState<ImageOCRResponse | null>(null)
const selectedElements = useMemo(
() => selectedIds.map(id => elements[id]).filter(Boolean),
[selectedIds, elements],
)
const selectedElement = elements[selectedIds[0]] as ImageElement | undefined
const { downloadImage, downloadCompositeImage } = useDownLoadImageGroup(
selectionBounds,
selectedElements,
)
const canvasInitViewport = useMemo(() => ({ scale: 0.25 }), [])
const canvasPadding = useMemo(
() => ({ top: 120, right: 20, bottom: 80, left: 120 }),
[],
)
const layoutOptions = useMemo(
() => ({
colMaxWidth,
colGap,
rowGap,
padding: canvasPadding,
}),
[colMaxWidth, colGap, rowGap, canvasPadding],
)
const { addElementToFlow } = useLayout(layoutOptions)
const {
handleAddArtboard,
handleAddImage,
handleAddImageFromFile,
handleAddText,
handleDeleteElements,
} = useElementActions(
props.taskId || '',
addElementToFlow,
instance.scrollIntoView,
)
const handleOnPartialRedraw = useCallback(
(
element: ImageElement,
recognizedElements: readonly RecognizedImageElement[],
) => {
store.getState().updateElementUIState(element.id, {
redrawState: {
marks: [],
inputValue: '',
recognizedElements: [...recognizedElements],
docJson: undefined,
},
})
},
[store],
)
const {
handleMatting,
handlePartialRedraw,
handleEditText,
handleConfirmEditText,
handleEditElements,
} = useImageEditActions(
props.taskId || '',
addElementToFlow,
handleOnPartialRedraw,
)
const handlePaste = usePasteHandler({
readOnly,
handleAddImageFromFile,
handleAddText,
})
useShortcuts({
rootRef,
onDelete: handleDeleteElements,
onPaste: handlePaste,
})
const renderElementMetadata = useCallback(
({ element }: { element: SceneElement }) => {
return (
<ElementMetadata
element={element}
onEditDimension={() => setEditingDimensionId(element.id)}
/>
)
},
[setEditingDimensionId],
)
const handleSelect = useCallback(
(ids: string[]) => {
const {
elements: currentElements,
viewport,
updateElement,
} = store.getState()
ids.forEach(id => {
const el = currentElements[id]
if (el && el.type === 'text' && (!el.width || !el.height)) {
const domEl = getDominoDOM(id)
if (domEl) {
const rect = domEl.getBoundingClientRect()
const scale = viewport.scale || 1
const updates: Partial<TextElement> = {}
if (!el.width) {
updates.width = Math.ceil(rect.width / scale) + 1
updates.originalWidth = updates.width
}
if (!el.height) {
updates.height = Math.ceil(rect.height / scale)
updates.originalHeight = updates.height
}
if (Object.keys(updates).length > 0) {
updateElement(id, updates)
}
}
}
})
},
[store],
)
return (
<div
ref={rootRef}
className={cn(
'domi-board relative w-full h-full overflow-hidden bg-bg text-font font-sans',
'group',
className,
)}
tabIndex={-1}
style={style}
>
{externalLoading ? (
<Loading />
) : (
<DominoCanvas
initViewport={canvasInitViewport}
minScale={0.02}
maxScale={4}
padding={canvasPadding}
readOnly={readOnly}
renderElementMetadata={renderElementMetadata}
onSelect={handleSelect}
elements={externalElements}
elementOrder={externalElementOrder}
>
<SelectionOverlay
elements={elements}
selectedIds={selectedIds}
selectionBounds={selectionBounds}
toolbarVisible={
selectedIds.length > 0 &&
!ocrData &&
!selectedIds.some(
id =>
!!(elementUIStates[id]?.redrawState as
| RedrawState
| undefined),
)
}
readOnly={readOnly}
onMatting={handleMatting}
onPartialRedraw={handlePartialRedraw}
onEditText={(e: ImageElement) => handleEditText(e, setOcrData)}
onEditElements={handleEditElements}
downloadImage={downloadImage}
downloadCompositeImage={downloadCompositeImage}
moveElementUp={instance.moveElementUp}
moveElementDown={instance.moveElementDown}
moveElementToTop={instance.moveElementToTop}
moveElementToBottom={instance.moveElementToBottom}
/>
{(() => {
if (!editingDimensionId) return null
const element = elements[editingDimensionId]
if (!element || element.type !== 'artboard') return null
return (
<ArtboardActionsToolbar
element={element}
onAction={actionId => {
if (actionId === 'download') {
downloadCompositeImage()
}
setEditingDimensionId(null)
}}
/>
)
})()}
<TopBar
rootRef={rootRef}
onBack={onBack}
onClose={onClose}
expand={expand}
onToggleExpand={onToggleExpand}
/>
{!readOnly && (
<BottomToolbar
onAddArtboard={handleAddArtboard}
onAddImage={handleAddImage}
onAddText={handleAddText}
/>
)}
</DominoCanvas>
)}
{ocrData && (
<DominoAnchor
id={selectedIds[0]}
containerRef={rootRef}
screenSpace
placement='left-start'
>
{({ style: anchorStyle, measureRef }) => (
<ImageTextEditor
measureRef={measureRef}
style={anchorStyle}
ocrList={ocrData}
onFinish={() => setOcrData(null)}
onConfirm={updatedList => {
handleConfirmEditText(
selectedElement as ImageElement,
updatedList,
)
}}
/>
)}
</DominoAnchor>
)}
{Object.entries(elements).map(([id, element]) => {
const uiState = elementUIStates[id]
const redrawState = uiState?.redrawState as RedrawState | undefined
if (!redrawState || element.type !== 'image') return null
return (
<ImageRedrawEditor
key={`redraw-${id}`}
element={element as ImageElement}
redrawState={redrawState}
showToolbar={selectedIds[0] === id}
addElementToFlow={addElementToFlow}
containerRef={rootRef}
taskId={props.taskId || ''}
onFinish={() => {
const { updateElementUIState } = store.getState()
updateElementUIState(id, {
status: 'idle',
statusText: undefined,
redrawState: undefined,
})
}}
/>
)
})}
</div>
)
})
export default memo(DomiBoard)

View File

@@ -0,0 +1,145 @@
import { useDrag } from '@use-gesture/react'
import { memo, useEffect, useRef, useState, useCallback } from 'react'
import { cn } from '@/utils/cn'
export interface DraggableProps {
className?: string
children: React.ReactNode
target?: string
bounds?:
| 'parent'
| 'window'
| { left: number; top: number; right: number; bottom: number }
offset?: { x: number; y: number }
innerRef?: (node: HTMLDivElement | null) => void
style?: React.CSSProperties
}
export const Draggable = memo(function Draggable(props: DraggableProps) {
const {
className,
children,
target,
bounds = 'window',
offset,
innerRef,
style,
} = props
const rootRef = useRef<HTMLDivElement | null>(null)
const [isDragging, setIsDragging] = useState(false)
// 响应外部 offset 变化
useEffect(() => {
if (offset && rootRef.current) {
rootRef.current.style.left = `${offset.x}px`
rootRef.current.style.top = `${offset.y}px`
}
}, [offset])
const bind = useDrag(
({ movement: [mx, my], first, last, memo, event, cancel }) => {
const el = rootRef.current
if (!el) return
// 1. 过滤掉交互性元素的拖拽请求(如按钮、输入框等)
if (first) {
const targetElement = event.target as HTMLElement
const isInteractive =
['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT'].includes(
targetElement.tagName,
) ||
targetElement.closest('button') ||
targetElement.closest('[data-no-drag]')
if (isInteractive) {
cancel()
return
}
}
// 2. 处理句柄逻辑
if (first && target) {
const handle = el.querySelector(target)
if (handle && !handle.contains(event.target as Node)) {
cancel()
return
}
}
// 3. 拖拽开始:实时从 DOM 获取起点坐标,解决“跳动”问题的关键
if (first) {
setIsDragging(true)
const s = window.getComputedStyle(el)
const l = parseInt(s.left)
const t = parseInt(s.top)
// 即时获取物理坐标
const curX = isNaN(l) ? el.offsetLeft : l
const curY = isNaN(t) ? el.offsetTop : t
const rect = el.getBoundingClientRect()
return {
startX: curX,
startY: curY,
dx: rect.left - curX, // 屏幕坐标与 style 坐标的差值
dy: rect.top - curY,
width: rect.width,
height: rect.height,
}
}
if (last) {
setIsDragging(false)
}
// 如果 memo 没初始化(比如被取消了),不执行后续逻辑
if (!memo) return
// 3. 计算新坐标
let x = memo.startX + mx
let y = memo.startY + my
// 4. 边界处理
if (bounds === 'window') {
const { dx, dy, width, height } = memo
x = Math.max(-dx, Math.min(window.innerWidth - width - dx, x))
y = Math.max(-dy, Math.min(window.innerHeight - height - dy, y))
}
// 5. 直接更新 DOM 提升性能
el.style.left = `${x}px`
el.style.top = `${y}px`
return memo
},
{
pointer: { capture: true },
filterTaps: true, // 过滤掉无位移点击
},
)
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
rootRef.current = node
innerRef?.(node)
},
[innerRef],
)
return (
<div
{...bind()}
ref={setRefs}
className={cn('w-fit h-fit', className)}
style={{
position: 'absolute',
touchAction: 'none',
userSelect: isDragging ? 'none' : 'auto',
transition: isDragging ? 'none' : undefined, // 拖拽时必须禁用过渡
...style,
}}
>
{children}
</div>
)
})

View File

@@ -0,0 +1,60 @@
import { memo } from 'react'
import { cn } from '@/utils/cn'
import { Icon } from '../ui/icon'
import type { SceneElement } from '../canvas'
export interface ElementMetadataProps {
element: SceneElement
onEditDimension?: (id: string) => void
}
export const ElementMetadata = memo(
({ element, onEditDimension }: ElementMetadataProps) => {
const isImage = element.type === 'image'
const isArtboard = element.type === 'artboard'
if (!isImage && !isArtboard) return null
return (
<div className='flex w-full items-center justify-between whitespace-nowrap font-medium text-[var(--editor-text-muted)]'>
<div className='domino-element-file-name flex items-center gap-[4px] min-w-0 pointer-events-auto'>
{isArtboard && (
<>
<Icon
icon='artboard-gray'
className='flex-shrink-0'
style={{ width: 12, height: 12 }}
/>
<span className='truncate'>{element.name || '画板'}</span>
</>
)}
{isImage && <span className='truncate'>{element.fileName}</span>}
</div>
<div
onPointerDown={e => e.stopPropagation()}
onClick={e => {
if (isArtboard) {
e.stopPropagation()
onEditDimension?.(element.id)
}
}}
className={cn(
'domino-element-dimension opacity-60 flex-shrink-0 pointer-events-auto',
isArtboard && 'hover:text-primary cursor-pointer transition-colors',
)}
>
{isImage && (
<>
{element.originalWidth}*{element.originalHeight}
</>
)}
{isArtboard && (
<>
{element.width.toFixed(0)}*{element.height.toFixed(0)}
</>
)}
</div>
</div>
)
},
)

View File

@@ -0,0 +1,215 @@
import React from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import { cn } from '@/utils/cn'
import { getDominoDOM, type ImageElement, type Viewport } from '../canvas'
import { LocalIcon } from '../ui/local-icon'
interface ImageActionsToolbarProps {
element: ImageElement
viewport: Viewport
onAction?: (actionId: string) => void
readOnly?: boolean
}
const TOOLBAR_OFFSET_Y = -20
export const ImageActionsToolbar: React.FC<ImageActionsToolbarProps> = ({
element,
viewport,
onAction,
readOnly,
}) => {
const [isVisible, setIsVisible] = React.useState(true)
const containerRef = React.useRef<HTMLDivElement>(null)
const { id, x, y, width, height, rotation = 0 } = element
const { x: vx, y: vy, scale: vs } = viewport
// Reset visibility when the selected element changes
React.useEffect(() => {
setIsVisible(true)
}, [id])
React.useEffect(() => {
const handlePointerDown = (e: PointerEvent) => {
const dom = getDominoDOM(id)
if (dom && dom.contains(e.target as Node)) {
setIsVisible(true)
}
}
// capture 阶段,防止其他地方阻止冒泡导致监听不到
window.addEventListener('pointerdown', handlePointerDown, { capture: true })
return () => {
window.removeEventListener('pointerdown', handlePointerDown, {
capture: true,
})
}
}, [id])
const handleAction = React.useCallback(
(actionId: string) => {
setIsVisible(false)
onAction?.(actionId)
},
[onAction],
)
const actions = [
{
id: 'edit-text',
icon: 'edit-text',
label: '编辑文字',
hidden: readOnly,
},
{
id: 'edit-elements',
icon: 'edit-elements',
label: '编辑元素',
hidden: readOnly,
},
{
id: 'partial-redraw',
icon: 'partial-redraw',
label: '局部重绘',
hidden: readOnly,
},
{
id: 'image-matting',
icon: 'image-matting',
label: '抠图',
hidden: readOnly,
},
{ id: 'download', icon: 'download', isIconOnly: true },
{
id: 'more',
icon: 'more',
isIconOnly: true,
hasDropdown: true,
hidden: readOnly,
},
].filter(action => !action.hidden)
const dropdownItems = [
{
key: 'layer-up',
label: (
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
<LocalIcon
name='layer-up'
className='h-[16px] w-[16px] text-foreground'
/>
<span className='text-[14px] whitespace-nowrap'></span>
</div>
),
},
{
key: 'layer-down',
label: (
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
<LocalIcon
name='layer-down'
className='h-[16px] w-[16px] text-foreground'
/>
<span className='text-[14px] whitespace-nowrap'></span>
</div>
),
},
{
key: 'layer-top',
label: (
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
<LocalIcon
name='move-to-top'
className='h-[16px] w-[16px] text-foreground'
/>
<span className='text-[14px] whitespace-nowrap'></span>
</div>
),
},
{
key: 'layer-bottom',
label: (
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
<LocalIcon
name='move-to-bottom'
className='h-[16px] w-[16px] text-foreground'
/>
<span className='text-[14px] whitespace-nowrap'></span>
</div>
),
},
]
if (!isVisible) return null
return (
<div
className='domino-toolbar absolute pointer-events-none z-[10]'
style={{
left: x * vs + vx,
top: (y + TOOLBAR_OFFSET_Y) * vs + vy,
width: width * vs,
height: height * vs,
transform: `rotate(${rotation}deg)`,
}}
>
<div
ref={containerRef}
className='editor-floating-panel-soft absolute left-1/2 flex items-center gap-[4px] rounded-full p-[4px] pointer-events-auto'
style={{
bottom: '100%',
marginBottom: '12px',
transform: 'translate(-50%, 0)',
}}
>
{actions.map(action => {
const button = (
<div
key={action.id}
className={cn(
'flex h-[28px] cursor-pointer items-center gap-[6px] rounded-[12px] px-[8px] text-foreground transition-colors hover:bg-accent whitespace-nowrap',
action.isIconOnly && 'px-[4px]',
)}
onClick={() => !action.hasDropdown && handleAction(action.id)}
>
<LocalIcon
name={action.icon}
className={'w-[16px] h-[16px] text-foreground'}
/>
{action.label && (
<span className='text-[14px] font-normal text-foreground'>
{action.label}
</span>
)}
</div>
)
if (action.hasDropdown) {
return (
<DropdownMenu key={action.id}>
<DropdownMenuTrigger asChild>{button}</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='end'>
{dropdownItems.map(item => (
<DropdownMenuItem
key={item.key}
onClick={() => handleAction(item.key)}
className='focus:bg-accent data-[highlighted]:bg-accent cursor-pointer'
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}
return button
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,124 @@
import React from 'react'
import { cn } from '@/utils/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import type { Viewport } from '../canvas'
import { LocalIcon } from '../ui/local-icon'
interface ImageActionsToolbarProps {
element: {
x: number
y: number
width: number
height: number
rotation?: number
}
viewport: Viewport
onAction?: (actionId: 'download-group' | 'download-multi') => void
readOnly?: boolean
}
const TOOLBAR_OFFSET_Y = -20
export const GroupActionsToolbar: React.FC<ImageActionsToolbarProps> = ({
element,
viewport,
onAction,
}) => {
const containerRef = React.useRef<HTMLDivElement>(null)
const { x, y, width, height, rotation = 0 } = element
const { x: vx, y: vy, scale: vs } = viewport
const DOWNLOADAACIONS = [
{
id: 'download-group',
icon: (
<LocalIcon
name='image-editor-image-download-compose'
className='w-[16px] h-[16px]'
/>
),
label: <span className='whitespace-nowrap'></span>,
},
// {
// id: 'download-multi',
// icon: (
// <LocalIcon name="image-editor-image-download-muti" className="w-[16px] h-[16px]" />
// ),
// label: (
// <span className='whitespace-nowrap'>
// 批量下载
// </span>
// ),
// },
]
return (
<div
className='domino-toolbar absolute pointer-events-none z-[10]'
style={{
left: x * vs + vx,
top: (y + TOOLBAR_OFFSET_Y) * vs + vy,
width: width * vs,
height: height * vs,
transform: `rotate(${rotation}deg)`,
}}
>
<div
ref={containerRef}
className={cn(
'absolute left-1/2 flex items-center gap-[4px] bg-popover/90 backdrop-blur-md border border-border shadow-xl rounded-full p-[2px] pointer-events-auto',
// readOnly && 'hidden' // We usually allow download in readOnly mode
)}
style={{
bottom: '100%',
marginBottom: '12px',
transform: 'translate(-50%, 0)',
}}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn(
'flex items-center justify-center w-[28px] h-[28px] rounded-[14px] cursor-pointer transition-colors flex-shrink-0',
'text-foreground hover:bg-accent',
)}
>
<LocalIcon
name='download'
className='w-[16px] h-[16px]'
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='end'>
{DOWNLOADAACIONS.map(actions => (
<DropdownMenuItem
key={actions.id}
onClick={() => {
switch (actions.id) {
case 'download-group':
onAction?.('download-group')
break
case 'download-multi':
onAction?.('download-multi')
break
}
}}
className='focus:bg-accent data-[highlighted]:bg-accent cursor-pointer'
>
<div className='flex items-center gap-[8px] px-[4px] py-[2px] text-[14px] text-foreground'>
{actions.icon}
<span className='whitespace-nowrap'>{actions.label}</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@@ -0,0 +1,447 @@
import type { CSSProperties } from 'react'
import React, {
memo,
useRef,
useEffect,
useCallback,
useMemo,
useState,
} from 'react'
import { v4 as uuidv4 } from 'uuid'
import { toast } from 'sonner'
import type {
SimpleRect,
RecognizedImageElement,
} from '../../service/type'
import { redrawImage } from '../../service/api'
import { Button } from '../ui/button'
import { cn } from '@/utils/cn'
import { useDominoStore, useDominoStoreInstance, DominoAnchor } from '../canvas'
import type { ImageElement, SceneElement, PlaceholderElement } from '../canvas'
import {
flyParabola,
getImageDimension,
getImageUrl,
manualPersistence,
} from '../../utils/helper'
import { NEED_COMPRESS_MAX_SIZE } from '../../consts'
import type { InlineMarkEditorRef } from './inline-mark-editor'
import { InlineMarkEditor } from './inline-mark-editor'
export interface MarkWithMetadata extends SimpleRect {
index: number
elementName?: string
elementType?: string
clickX: number
clickY: number
}
export interface RedrawState {
marks: MarkWithMetadata[]
inputValue: string
recognizedElements: RecognizedImageElement[]
docJson?: any
}
export interface ImageRedrawEditorProps {
className?: string
style?: CSSProperties
element: ImageElement
maxMarkCount?: number
onFinish?: () => void
addElementToFlow: (elementTemplate: SceneElement) => SceneElement
redrawState: RedrawState
showToolbar: boolean
containerRef: React.RefObject<HTMLElement | null>
taskId: string
}
function ImageRedrawEditor(props: ImageRedrawEditorProps) {
const {
element,
className,
style,
onFinish,
maxMarkCount = 10,
addElementToFlow,
redrawState,
showToolbar,
containerRef,
taskId,
} = props
const store = useDominoStoreInstance()
const editorRef = useRef<InlineMarkEditorRef>(null)
const svgRef = useRef<SVGSVGElement>(null)
const [isDebug, setIsDebug] = useState(false)
const { marks, inputValue, recognizedElements, docJson } = redrawState
const isFull = marks.length >= maxMarkCount
const updateRedrawState = useCallback(
(updates: Partial<RedrawState>) => {
const { updateElementUIState } = store.getState()
updateElementUIState(element.id, {
redrawState: {
...redrawState,
...updates,
},
})
},
[element.id, redrawState, store],
)
const { viewport } = useDominoStore(s => ({
viewport: s.viewport,
}))
const { x: vx, y: vy, scale: vs } = viewport
const {
x,
y,
width,
height,
rotation = 0,
originalWidth = 0,
originalHeight = 0,
} = element
const viewBox = useMemo<[number, number, number, number]>(
() => [0, 0, originalWidth || width, originalHeight || height],
[originalWidth, width, originalHeight, height],
)
const handleImageClick = useCallback(
(px: number, py: number, screenPoint: { x: number; y: number }) => {
if (isFull) return
// 1. 寻找被点击位置所属的识别区域(仅用于获取语义名称,不用于定位)
const foundElement = [...recognizedElements]
.filter(
el =>
px >= el.x && px <= el.x + el.w && py >= el.y && py <= el.y + el.h,
)
.sort((a, b) => a.w * a.h - b.w * b.h)[0]
// 2. 构造标记数据
const nextIndex = marks.length + 1
const markW = 40 // 回退标记块宽度
const markH = 40 // 回退标记块高度
const markData: MarkWithMetadata = {
x: foundElement ? foundElement.x : px - markW / 2,
y: foundElement ? foundElement.y : py - markH / 2,
width: foundElement ? foundElement.w : markW,
height: foundElement ? foundElement.h : markH,
index: nextIndex,
elementName: foundElement?.element,
elementType: foundElement?.type,
clickX: px,
clickY: py,
}
// 3. 插入编辑器节点
editorRef.current?.insertMark(markData)
// 4. 触发飞入动画
const from = { x: screenPoint.x, y: screenPoint.y }
requestAnimationFrame(() => {
const to = document
.querySelector(`.pe-image-mark[data-index="${nextIndex}"]`)
?.getBoundingClientRect()
if (to) flyParabola(nextIndex, from, to)
})
},
[
isFull,
recognizedElements,
marks.length,
viewBox,
element.src,
flyParabola,
],
)
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
if (!showToolbar) return
// 检查点击是否发生在重绘编辑器的 UI 上(如工具栏、输入框)
if ((e.target as HTMLElement).closest('.pe-image-redraw-ui')) return
const svg = svgRef.current
if (!svg) return
const pt = svg.createSVGPoint()
pt.x = e.clientX
pt.y = e.clientY
const ctm = svg.getScreenCTM()
if (!ctm) return
const { x: px, y: py } = pt.matrixTransform(ctm.inverse())
// 检查点击是否落在图片有效像素范围内
if (px >= 0 && px <= viewBox[2] && py >= 0 && py <= viewBox[3]) {
// 如果是左键点击且没有按下修饰键,则视为添加标号
if (e.button === 0 && !e.altKey && !e.ctrlKey && !e.metaKey) {
handleImageClick(px, py, { x: e.clientX, y: e.clientY })
// 阻止事件冒泡到画布,防止选择状态被改变(例如因点击层级关系导致的误判)
e.stopPropagation()
e.preventDefault()
}
}
}
window.addEventListener('pointerdown', onPointerDown, { capture: true })
return () => {
window.removeEventListener('pointerdown', onPointerDown, {
capture: true,
})
}
}, [handleImageClick, viewBox, showToolbar])
const handleCancel = () => {
onFinish?.()
}
const handleConfirm = async (e: React.KeyboardEvent | React.MouseEvent) => {
e.stopPropagation()
if (e instanceof KeyboardEvent && e.shiftKey) return
if (!inputValue || !marks.length) return
const placeholderId = uuidv4()
const placeholderElement: PlaceholderElement = {
id: placeholderId,
type: 'placeholder',
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.width,
originalHeight: element.height,
label: '正在重绘...',
}
const placeholder = addElementToFlow(placeholderElement)
store.getState().updateElementUIState(placeholderId, {
status: 'pending',
})
onFinish?.()
try {
const result = await redrawImage({
image_url: element.data?.path || element.id,
prompt: inputValue,
task_id: taskId,
compress: (element.data?.size ?? 0) > NEED_COMPRESS_MAX_SIZE,
})
const url = await getImageUrl(taskId, result.path)
const { width, height } = await getImageDimension(url)
const { addElement, removePlaceholder, updateElementUIState } =
store.getState()
// Replace placeholder with final result
removePlaceholder(placeholderId)
const finalPath = result.path || result.url
if (!finalPath) {
throw new Error('No path or URL returned from redraw')
}
const newImage: ImageElement = {
...placeholder,
id: finalPath,
type: 'image',
src: url,
width,
height,
originalWidth: width,
originalHeight: height,
fileName: result.file_name || element.fileName,
data: {
path: finalPath,
},
}
addElement(newImage)
updateElementUIState(finalPath, {
status: 'idle',
statusText: undefined,
})
// Support background task persistence
manualPersistence(taskId, store)
toast.success('重绘完成')
} catch (e) {
toast.error('对不起,处理失败')
console.error(e)
const { removePlaceholder } = store.getState()
removePlaceholder(placeholderId)
}
}
return (
<div
className={cn(
'image-redraw-editor absolute inset-0 z-[70] pointer-events-none',
className,
)}
style={style}
>
{/* Positioned Editing Box (Sticks to Canvas Image) */}
<div
className='absolute pointer-events-none'
style={{
left: x * vs + vx,
top: y * vs + vy,
width: width * vs,
height: height * vs,
transform: `rotate(${rotation}deg)`,
transformOrigin: '50% 50%',
}}
>
{/* Layer 1: Coordinate Reference (Invisible and Non-Blocking) */}
<svg
ref={svgRef}
className='absolute inset-0 w-full h-full select-none pointer-events-none'
viewBox={viewBox?.join(' ')}
>
<rect width='100%' height='100%' fill='transparent' />
{isDebug &&
recognizedElements.map((el, i) => (
<g key={i}>
<rect
x={el.x}
y={el.y}
width={el.w}
height={el.h}
fill='rgba(255, 0, 0, 0.1)'
stroke='red'
strokeWidth='1'
style={{ pointerEvents: 'none', userSelect: 'none' }}
/>
<text
x={el.x + 4 / vs}
y={el.y + 12 / vs}
fill={'rgba(255, 0, 0, 0.6)'}
fontSize={10 / vs}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{el.element}
</text>
</g>
))}
</svg>
{/* Layer 2: Display Layer (HTML-based markers) */}
<div className='absolute inset-0 flex items-center justify-center pointer-events-none'>
{/* 这里通过 aspect-ratio 模拟图片 object-contain 的实际渲染区域 */}
<div
className='relative pointer-events-none'
style={{
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
aspectRatio: `${viewBox[2]} / ${viewBox[3]}`,
}}
>
{marks.map(mark => {
// 标号位置优先以精确点击位置为准,无则取区域中心
const cx = mark.clickX ?? mark.x + mark.width / 2
const cy = mark.clickY ?? mark.y + mark.height / 2
const left = (cx / viewBox[2]) * 100
const top = (cy / viewBox[3]) * 100
return (
<div
key={mark.index}
className={cn(
'image-redraw-editor-image-mark',
'absolute flex items-center justify-center rounded-full border-2 border-white',
'bg-primary text-primary-foreground font-bold shadow-[0_2px_10px_rgba(16,37,51,0.24)]',
'z-10',
)}
style={{
left: `${left}%`,
top: `${top}%`,
width: '22px',
height: '22px',
fontSize: '12px',
transform: `translate(-50%, -50%) rotate(${-rotation}deg)`,
zIndex: 20,
pointerEvents: 'none',
}}
>
{mark.index}
</div>
)
})}
</div>
</div>
</div>
{showToolbar && (
<DominoAnchor
id={element.id}
containerRef={containerRef}
placement='bottom'
className={cn(
'pe-image-redraw-input',
'z-3 flex gap-[2px] p-[4px] rounded-[12px]',
'editor-floating-panel-soft',
// 默认响应点击
'pointer-events-auto',
// 状态联动:当画布处于特定交互状态时,强制禁止 UI 拦截事件
// 1. 兄弟选择器联动 (Canvas 是前置兄弟)
'[.is-panning~&]:!pointer-events-none [.is-zooming~&]:!pointer-events-none',
'[.is-dragging~&]:!pointer-events-none',
// 2. 祖先组状态联动 (DomiBoard 是 group)
'group-has-[.is-panning]:!pointer-events-none group-has-[.is-zooming]:!pointer-events-none',
'group-has-[.is-dragging]:!pointer-events-none',
)}
>
<div className='editor-toolbar-chip flex flex-1 items-center rounded-[8px] px-[6px]'>
<InlineMarkEditor
key={element.id}
className='flex w-[320px] max-h-[180px] text-[14px] font-medium'
ref={editorRef}
initialHtml={docJson?.html}
placeholder='请点击标记你想要修改的内容'
onChange={(
val: string,
newMarks: MarkWithMetadata[],
html: string,
) => {
updateRedrawState({
inputValue: val,
marks: newMarks,
docJson: { html },
})
}}
onEnter={handleConfirm}
/>
</div>
<div className='flex gap-[2px] items-end justify-center pb-4px'>
<Button size='sm' variant='outline' onClick={handleCancel}>
</Button>
<Button
size='sm'
variant='default'
disabled={!inputValue || !marks.length}
onClick={handleConfirm}
>
</Button>
</div>
</DominoAnchor>
)}
</div>
)
}
export default memo(ImageRedrawEditor)

View File

@@ -0,0 +1,169 @@
import type {
ImageOCRResponse,
ImageOCRTextUpdateItem,
} from '../../service/type'
import type { CSSProperties } from 'react'
import { memo, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Input } from '../ui/input'
import { Button } from '../ui/button'
import { cn } from '@/utils/cn'
import { Icon } from '../ui/icon'
import { Draggable } from './draggable'
export interface ImageTextEditorProps {
className?: string
ocrList: ImageOCRResponse
onFinish?: () => void
onConfirm?: (updatedList: ImageOCRTextUpdateItem[]) => void
style?: CSSProperties
measureRef?: (node: HTMLElement | null) => void
}
const MODAL_WIDTH = 520
function ImageTextEditor(props: ImageTextEditorProps) {
const { className, ocrList, onFinish, onConfirm, style, measureRef } = props
const [values, setValues] = useState<Record<string | number, string>>(() =>
ocrList.reduce(
(acc, item, index) => {
acc[index] = item.line_text
return acc
},
{} as Record<string | number, string>,
),
)
const handleOK = async () => {
const updatedList: ImageOCRTextUpdateItem[] = []
for (const key in values) {
if (!Object.hasOwn(values, key)) continue
const target = values[key]
if (target !== ocrList[Number(key)].line_text) {
const index = Number(key)
const { line_rect: rect, line_text: source } = ocrList[index]
updatedList.push({
source,
target,
...rect,
w: rect.width,
h: rect.height,
})
}
}
if (updatedList.length > 0) {
onConfirm?.(updatedList)
}
onFinish?.()
}
const handleCancel = () => {
onFinish?.()
}
return createPortal(
<div className={cn('fixed inset-0 z-40 w-full h-full', className)}>
<div
className='fixed inset-0 bg-[rgba(15,28,36,0.08)]'
onClick={handleCancel}
/>
<Draggable
innerRef={measureRef}
className={cn(
'image-text-editor-custom',
'absolute z-50 flex flex-col rounded-[16px]',
'editor-floating-panel pointer-events-auto',
)}
target='.modal-header'
style={{
...style,
width: MODAL_WIDTH,
}}
>
{/* Header */}
<div
className='modal-header flex cursor-move select-none items-center justify-between border-0 border-b-[0.5px] border-solid px-[20px] py-[16px]'
style={{ borderColor: 'var(--editor-border)' }}
>
<span className='text-[16px] font-600 text-foreground'></span>
<div
onClick={handleCancel}
data-no-drag
className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent'
>
<Icon icon='close' className='text-[18px]' />
</div>
</div>
{/* Content */}
{useMemo(
() => (
<div
className='flex-1 p-[24px] overflow-y-auto overlay-scrollbar'
style={{ maxHeight: '60vh' }}
>
<form
id='image-text-editor-form'
onSubmit={e => {
e.preventDefault()
handleOK()
}}
>
{ocrList.map((ocr, index) => (
<div key={index} style={{ marginBottom: '16px' }}>
<Input
value={values[index] ?? ocr.line_text}
onChange={e =>
setValues(prev => {
if (prev[index] === e.target.value) return prev
return { ...prev, [index]: e.target.value }
})
}
placeholder={ocr.line_text}
className='h-[32px] border-border bg-secondary/60 px-[12px] focus:ring-1 focus:ring-primary/30'
/>
</div>
))}
</form>
</div>
),
[ocrList, values],
)}
{/* Footer */}
<div
className='flex items-center justify-end gap-[12px] rounded-b-16px border-0 border-t-[0.5px] border-solid px-[20px] py-[16px]'
style={{
borderColor: 'var(--editor-border)',
background: 'var(--editor-surface-muted)',
}}
>
<Button
onClick={handleCancel}
data-no-drag
className='w-[100px]'
variant='outline'
>
</Button>
<Button
className='w-[100px]'
variant='default'
onClick={handleOK}
data-no-drag
>
</Button>
</div>
</Draggable>
</div>,
document.body,
)
}
export default memo(ImageTextEditor)

View File

@@ -0,0 +1,209 @@
import React, {
forwardRef,
useImperativeHandle,
useRef,
useEffect,
useState,
} from 'react'
import { cn } from '@/utils/cn'
import type { MarkWithMetadata } from './image-redraw-editor'
export interface InlineMarkEditorRef {
insertMark: (mark: MarkWithMetadata) => void
focus: () => void
}
export interface InlineMarkEditorProps {
className?: string
placeholder?: string
initialHtml?: string
onChange?: (val: string, marks: MarkWithMetadata[], html: string) => void
onEnter?: (e: React.KeyboardEvent | React.MouseEvent) => void
}
export const InlineMarkEditor = forwardRef<
InlineMarkEditorRef,
InlineMarkEditorProps
>((props, ref) => {
const { className, placeholder, initialHtml, onChange, onEnter } = props
const editorRef = useRef<HTMLDivElement>(null)
const [isEmpty, setIsEmpty] = useState(!initialHtml || initialHtml === '<br>')
useEffect(() => {
if (
editorRef.current &&
initialHtml &&
editorRef.current.innerHTML === ''
) {
editorRef.current.innerHTML = initialHtml
setIsEmpty(initialHtml === '' || initialHtml === '<br>')
}
}, [initialHtml])
const notifyChange = () => {
if (!editorRef.current) return
const root = editorRef.current
let val = ''
const newMarks: MarkWithMetadata[] = []
// 递归遍历 DOM提取文本和标记
const traverse = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
// 处理无宽空格和普通文本
val += (node.textContent || '').replace(/\u200B/g, '')
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement
if (el.tagName === 'BR') {
val += '\n'
} else if (el.dataset.mark) {
try {
const markObj = JSON.parse(
decodeURIComponent(el.dataset.mark),
) as MarkWithMetadata
newMarks.push(markObj)
const { index, x, y, width, height, elementName, elementType } =
markObj
val += `[@image-mark:#${index},@rect:${Math.round(x || 0)},${Math.round(
y || 0,
)},${Math.round(width || 0)},${Math.round(height || 0)},@element:${
elementName || 'undefined'
},@type:${elementType || 'undefined'}]`
} catch (e) {
// ignore
}
} else {
// 对于非内联块元素,处理换行逻辑
if (el.tagName === 'DIV' || el.tagName === 'P') {
if (val.length > 0 && !val.endsWith('\n')) val += '\n'
}
Array.from(node.childNodes).forEach(traverse)
}
}
}
Array.from(root.childNodes).forEach(traverse)
const textIsEmpty = root.innerText.replace(/\u200B/g, '').trim() === ''
const marksIsEmpty = root.querySelectorAll('[data-mark]').length === 0
setIsEmpty(textIsEmpty && marksIsEmpty)
onChange?.(val, newMarks, root.innerHTML)
}
useImperativeHandle(ref, () => ({
insertMark: (mark: MarkWithMetadata) => {
const editor = editorRef.current
if (!editor) return
editor.focus()
const selection = window.getSelection()
let range: Range
if (selection && selection.rangeCount > 0) {
range = selection.getRangeAt(0)
// 确保光标在编辑器内部
if (!editor.contains(range.commonAncestorContainer)) {
range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false) // 移到末尾
}
} else {
range = document.createRange()
range.selectNodeContents(editor)
range.collapse(false)
}
const markSpan = document.createElement('span')
markSpan.contentEditable = 'false'
markSpan.className =
'pe-image-mark mx-[2px] inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground font-bold select-none cursor-default align-middle'
markSpan.style.width = '20px'
markSpan.style.height = '20px'
markSpan.style.fontSize = '12px'
markSpan.dataset.mark = encodeURIComponent(JSON.stringify(mark))
markSpan.dataset.index = mark.index.toString()
markSpan.innerText = mark.index.toString()
range.deleteContents()
range.insertNode(markSpan)
// 插入零宽空格以便于继续输入
const spaceNode = document.createTextNode('\u200B')
range.setEndAfter(markSpan)
range.collapse(false)
range.insertNode(spaceNode)
range.setStartAfter(spaceNode)
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
notifyChange()
},
focus: () => {
editorRef.current?.focus()
},
}))
const handleInput = () => {
notifyChange()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onEnter?.(e)
} else if (e.key === 'Backspace') {
// 处理零宽空格和删除问题
const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
if (range.collapsed) {
const pre = range.startContainer.textContent?.substring(
0,
range.startOffset,
)
if (pre?.endsWith('\u200B')) {
// 光标前是零宽空格
range.setStart(range.startContainer, range.startOffset - 1)
range.deleteContents()
}
}
}
}
setTimeout(notifyChange, 0)
}
return (
<div
className={cn('relative cursor-text flex items-center', className)}
onClick={() => {
if (editorRef.current && document.activeElement !== editorRef.current) {
editorRef.current.focus()
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(editorRef.current)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
}
}}
>
{isEmpty && placeholder && (
<div className='absolute top-1/2 -translate-y-1/2 left-[8px] text-gray-400 pointer-events-none select-none text-[14px]'>
{placeholder}
</div>
)}
<div
ref={editorRef}
contentEditable
className='outline-none font-normal text-[14px] whitespace-pre-wrap break-words min-h-[24px] w-full max-h-[180px] overflow-y-auto px-[8px] py-[4px] leading-relaxed text-[var(--text-main,inherit)]'
onInput={handleInput}
onKeyDown={handleKeyDown}
onBlur={handleInput}
/>
</div>
)
})
InlineMarkEditor.displayName = 'InlineMarkEditor'

View File

@@ -0,0 +1,23 @@
import { memo } from 'react'
export const Loading = memo(() => (
<div className='absolute inset-0 flex flex-col items-center justify-center bg-muted animate-[fade-in_0.3s_ease-out] z-[100]'>
<div className='relative h-[4px] w-[200px] overflow-hidden rounded-full bg-border/50'>
<div className='absolute inset-y-0 h-full rounded-full bg-primary/40 animate-[loading_1.5s_infinite_linear]' />
</div>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes loading {
0% { left: -40%; width: 40%; }
100% { left: 100%; width: 40%; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
`,
}}
/>
</div>
))

View File

@@ -0,0 +1,232 @@
import type { TaskArtifact } from '../../service/type'
import { memo, useEffect, useMemo, useState, forwardRef, useImperativeHandle } from 'react'
import { ErrorBoundary } from '../ui/error-boundary'
import {
DominoProvider,
createDominoStore,
useDominoStoreInstance,
type DominoStore,
type ImageElement,
} from '../canvas'
import { usePersistence } from '../../hooks/use-persistence'
import { getImageUrl, getImageDimension } from '../../utils/helper'
import { useLayout } from '../../hooks/use-layout'
import { cn } from '@/utils/cn'
import DomiBoard, { type DomiBoardProps } from './domi-board'
export interface ImageEditorProps extends DomiBoardProps {
/**
* 任务ID/会话ID
*/
taskId: string
/**
* 当前产物,更新这个值可能会追加到编辑器中
*/
currentArtifact?: TaskArtifact
}
export interface ImageEditorHandle {
/**
* 批量添加产物到编辑器,并自动布局
* @param artifacts 产物列表
* @param options.autoFocus 是否在添加完成后聚焦到最后一个产物,默认为 true
*/
addArtifacts: (
artifacts: TaskArtifact[],
options?: { autoFocus?: boolean },
) => Promise<void>
}
const storeRegistry = new Map<string, DominoStore>()
function getOrCreateStore(taskId: string) {
if (!storeRegistry.has(taskId)) {
storeRegistry.set(
taskId,
createDominoStore({ viewport: { x: 0, y: 0, scale: 0.25 } }),
)
}
return storeRegistry.get(taskId)!
}
const EditorCanvasManager = memo(
forwardRef<ImageEditorHandle, ImageEditorProps>((props, ref) => {
const { className, currentArtifact, taskId, ...rest } = props
const [loading, setLoading] = useState(!!taskId)
const { loadBoard, initialized } = usePersistence({ taskId, setLoading })
const store = useDominoStoreInstance()
const colMaxWidth = props.colMaxWidth || 7000
const colGap = props.colGap || 40
const rowGap = props.rowGap || 120
const layoutOptions = useMemo(
() => ({
maxWidth: colMaxWidth,
colGap,
rowGap,
padding: { top: 80, left: 80, bottom: 80, right: 80 },
}),
[colMaxWidth, colGap, rowGap],
)
const { addElementToFlow } = useLayout(layoutOptions)
const addArtifacts = useMemo(
() =>
async (
artifacts: TaskArtifact[],
options: { autoFocus?: boolean } = { autoFocus: true },
) => {
let lastId: string | null = null
for (const artifact of artifacts) {
const id = artifact.sandbox_path || artifact.path
lastId = id
const { elements } = store.getState()
if (elements[id]) continue
let url = artifact.url
if (!url) {
try {
url = await getImageUrl(taskId, artifact.path)
} catch (e) {
console.warn('Failed to get image url', e)
continue
}
}
if (!url) continue
try {
const { width = 0, height = 0 } = await getImageDimension(url)
if (width === 0 || height === 0) continue
const newImage: ImageElement = {
id,
type: 'image',
x: 0,
y: 0,
width,
height,
originalWidth: width,
originalHeight: height,
src: url,
fileName:
(artifact.file_name || artifact.path).split('/').pop() ||
'image.jpg',
rotation: 0,
data: {
path: id,
},
}
addElementToFlow(newImage)
} catch (e) {
console.warn('Failed to add artifact to board', e)
}
}
if (options.autoFocus && lastId) {
store.getState().setFocusedElementId(lastId)
}
},
[taskId, store, addElementToFlow],
)
useImperativeHandle(ref, () => ({
addArtifacts,
}))
useEffect(() => {
const init = async () => {
await loadBoard()
}
init()
}, [loadBoard])
// 处理外部传入的当前产物:自动添加到画板并聚焦
useEffect(() => {
if (!initialized || !currentArtifact) return
const id = currentArtifact.sandbox_path || currentArtifact.path
const handleFocusOrAdd = async () => {
const { elements: currentElements, setFocusedElementId } =
store.getState()
if (!currentElements[id]) {
const { url: artifactUrl, file_name, path } = currentArtifact
let url = artifactUrl
if (!url) {
try {
url = await getImageUrl(taskId, path)
} catch (e) {
console.warn('Failed to get image url', e)
}
}
if (!url) {
console.warn('Failed to get image url')
return
}
try {
const { width = 0, height = 0 } = await getImageDimension(url)
if (width === 0 || height === 0) throw new Error('Image size is 0')
addElementToFlow({
id,
type: 'image',
x: 0,
y: 0,
width,
height,
originalWidth: width,
originalHeight: height,
src: url,
fileName: (file_name || path).split('/').pop() || 'image.jpg',
rotation: 0,
data: {
path: id,
},
})
} catch (e) {
console.warn('Failed to add current artifact to board', e)
}
}
setFocusedElementId(id)
}
handleFocusOrAdd()
}, [initialized, currentArtifact, addElementToFlow, taskId, store])
return (
<DomiBoard
{...rest}
className={cn('image-editor-sdk-root', className)}
loading={loading}
taskId={taskId}
/>
)
})
)
export const ImageEditor = memo(
forwardRef<ImageEditorHandle, ImageEditorProps>((props, ref) => {
const { taskId } = props
const store = useMemo(() => getOrCreateStore(taskId), [taskId])
if (!taskId) return null
return (
<ErrorBoundary>
<DominoProvider store={store}>
<EditorCanvasManager {...props} ref={ref} />
</DominoProvider>
</ErrorBoundary>
)
})
)
export default ImageEditor

View File

@@ -0,0 +1,170 @@
import React, { memo } from 'react'
import { getElementWorldRect, useDominoStore } from '../canvas'
import type {
ImageElement,
ArtboardElement,
TextElement,
SceneElement,
PlaceholderElement,
DominoStoreState,
} from '../canvas'
import { ImageActionsToolbar } from './image-actions-toolbar'
import { GroupActionsToolbar } from './image-group-toolbar'
import { TextToolbar } from './text-toolbar'
interface SelectionOverlayProps {
elements: Record<string, SceneElement | PlaceholderElement>
selectedIds: string[]
selectionBounds: {
left: number
top: number
width: number
height: number
} | null
toolbarVisible: boolean
onMatting: (element: ImageElement) => void
onPartialRedraw: (element: ImageElement) => void
onEditText: (element: ImageElement) => void
onEditElements: (element: ImageElement) => void
downloadImage: (url: string, fileName: string) => void
downloadCompositeImage: () => void
moveElementUp: (id: string) => void
moveElementDown: (id: string) => void
moveElementToTop: (id: string) => void
moveElementToBottom: (id: string) => void
readOnly?: boolean
}
const SelectionOverlay: React.FC<SelectionOverlayProps> = ({
elements,
selectedIds,
selectionBounds,
toolbarVisible,
onMatting,
onPartialRedraw,
onEditText,
onEditElements,
downloadImage,
downloadCompositeImage,
moveElementUp,
moveElementDown,
moveElementToTop,
moveElementToBottom,
readOnly,
}) => {
const viewport = useDominoStore((state: DominoStoreState) => state.viewport)
if (!toolbarVisible || !elements || !selectedIds || selectedIds.length === 0)
return null
const rawElement = elements[selectedIds[0]] as
| ImageElement
| ArtboardElement
| TextElement
| undefined
// 1. 单选情况
if (selectedIds.length === 1 && rawElement) {
const worldRect = getElementWorldRect(rawElement, elements)
const selectedElement = {
...rawElement,
x: worldRect.x,
y: worldRect.y,
rotation: worldRect.rotation,
} as typeof rawElement
if (selectedElement.type === 'image') {
return (
<ImageActionsToolbar
element={selectedElement}
viewport={viewport}
readOnly={readOnly}
onAction={(actionId: string) => {
switch (actionId) {
case 'download':
downloadImage(
selectedElement.src,
`${selectedElement.fileName}`,
)
break
case 'image-matting':
onMatting(selectedElement)
break
case 'partial-redraw':
onPartialRedraw(selectedElement)
break
case 'edit-text':
onEditText(selectedElement)
break
case 'edit-elements':
onEditElements(selectedElement)
break
case 'layer-up':
moveElementUp(selectedElement.id)
break
case 'layer-down':
moveElementDown(selectedElement.id)
break
case 'layer-top':
moveElementToTop(selectedElement.id)
break
case 'layer-bottom':
moveElementToBottom(selectedElement.id)
break
}
}}
/>
)
}
if (selectedElement.type === 'artboard') {
return null
}
if (selectedElement.type === 'text') {
return (
<TextToolbar
element={selectedElement as TextElement}
viewport={viewport}
readOnly={readOnly}
/>
)
}
}
// 2. 多选情况
if (selectedIds.length > 1 && selectionBounds) {
return (
<GroupActionsToolbar
element={{
x: selectionBounds.left,
y: selectionBounds.top,
width: selectionBounds.width,
height: selectionBounds.height,
rotation: 0,
}}
viewport={viewport}
readOnly={readOnly}
onAction={actionId => {
switch (actionId) {
case 'download-group':
downloadCompositeImage()
break
case 'download-multi': {
const selectElements = selectedIds
.map(id => elements[id])
.filter((el): el is ImageElement => el?.type === 'image')
selectElements.forEach(el => {
downloadImage(el.src, `${el.fileName}`)
})
break
}
}
}}
/>
)
}
return null
}
export default memo(SelectionOverlay)

View File

@@ -0,0 +1,382 @@
import React from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import { cn } from '@/utils/cn'
import type { TextElement, Viewport } from '../canvas'
import { useDominoStore, useDominoStoreInstance } from '../canvas'
import { useLocalFonts } from '../../hooks/use-local-fonts'
import { Icon } from '../ui/icon'
import { LocalIcon } from '../ui/local-icon'
interface TextToolbarProps {
element: TextElement
viewport: Viewport
readOnly?: boolean
}
const FONT_SIZES = [12, 14, 16, 18, 20, 24, 28, 32, 36, 40, 48, 56, 64, 72, 80]
const ALIGNMENTS = [
{
id: 'left',
icon: () => (
<LocalIcon
name='image-editor-text-align-left'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '左对齐',
},
{
id: 'center',
icon: () => (
<LocalIcon
name='image-editor-text-align-center'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '居中对齐',
},
{
id: 'right',
icon: () => (
<LocalIcon
name='image-editor-text-align-right'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '右对齐',
},
] as const
const LAYER = [
{
id: 'layer-up',
icon: () => (
<LocalIcon
name='image-editor-text-layer-up'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '上移一层',
},
{
id: 'layer-down',
icon: () => (
<LocalIcon
name='image-editor-text-layer-down'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '下移一层',
},
{
id: 'layer-top',
icon: () => (
<LocalIcon
name='move-to-top'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '置于顶层',
},
{
id: 'layer-bottom',
icon: () => (
<LocalIcon
name='move-to-bottom'
className='w-[16px] h-[16px] text-foreground'
/>
),
label: '置于底层',
},
] as const
export const TextToolbar: React.FC<TextToolbarProps> = ({
element,
viewport,
readOnly,
}) => {
const containerRef = React.useRef<HTMLDivElement>(null)
const store = useDominoStoreInstance()
const { x, y, width, rotation = 0 } = element
const { x: vx, y: vy, scale: vs } = viewport
const updateElement = useDominoStore(state => state.updateElement)
const moveElementUp = useDominoStore(state => state.moveElementUp)
const moveElementDown = useDominoStore(state => state.moveElementDown)
const moveElementToTop = useDominoStore(state => state.moveElementToTop)
const moveElementToBottom = useDominoStore(state => state.moveElementToBottom)
const handleUpdate = (updates: Partial<TextElement>) => {
store.getState().takeSnapshot()
updateElement(element.id, updates)
}
const handleUpdateLayer = (
key: 'layer-up' | 'layer-down' | 'layer-top' | 'layer-bottom',
) => {
const actions = {
'layer-up': moveElementUp,
'layer-down': moveElementDown,
'layer-top': moveElementToTop,
'layer-bottom': moveElementToBottom,
}
const handle = actions[key]
store.getState().takeSnapshot()
handle(element.id)
}
const { fonts } = useLocalFonts()
const currentAlign = ALIGNMENTS.find(
a => a.id === (element.textAlign || 'left'),
)
const fontOptions = React.useMemo(() => {
return fonts.map(f => ({
value: f.value,
label: (
<span
style={{
fontFamily: f.value,
fontSize: '14px',
lineHeight: '24px',
}}
>
{f.label}
</span>
),
searchValue: f.label,
}))
}, [fonts])
return (
<div
className='domino-toolbar h-[38px] absolute pointer-events-none z-[10]'
style={{
left: x * vs + vx,
top: y * vs + vy,
width: width * vs,
height: (element.height || 0) * vs,
transform: `rotate(${rotation}deg)`,
}}
>
<div
ref={containerRef}
className={cn(
'absolute left-1/2 flex items-center gap-[4px] bg-popover/90 backdrop-blur-md border border-border shadow-xl rounded-full px-[4px] pointer-events-auto whitespace-nowrap h-[38px]',
readOnly && 'hidden',
)}
style={{
bottom: '100%',
marginBottom: '12px',
transform: 'translate(-50%, 0)',
}}
>
{/* Color Picker Native */}
<div className='pl-4px pr-4px py-[4px] relative flex items-center justify-center'>
<input
type='color'
value={element.color || '#000000'}
onChange={e => handleUpdate({ color: e.target.value })}
className='absolute opacity-0 inset-0 w-full h-full cursor-pointer'
/>
<div
className='w-[20px] h-[20px] rounded-full cursor-pointer border border-black/5 pointer-events-none'
style={{ backgroundColor: element.color || '#000000' }}
/>
</div>
<div className='w-[1px] h-[16px] bg-border/60 flex-shrink-0' />
{/* Font Family Select (using DropdownMenu) */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className='flex items-center gap-[4px] px-[8px] h-[28px] rounded-[14px] cursor-pointer hover:bg-accent transition-colors flex-shrink-0 min-w-[80px]'>
<span className='text-[14px] truncate max-w-[80px]'>
{fonts.find(f => f.value === element.fontFamily)?.label ||
'黑体'}
</span>
<Icon
icon='dropdown'
className='text-foreground/40 ml-auto'
size='w-[16px] h-[16px] '
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
side='bottom'
align='start'
className='max-h-[300px] min-w-[100px]'
>
{fontOptions.map(opt => (
<DropdownMenuItem
key={opt.value}
onClick={() => handleUpdate({ fontFamily: opt.value })}
className='focus:bg-accent data-[highlighted]:bg-accent cursor-pointer'
>
<div className='flex items-center px-[4px] w-full'>
{opt.label}
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Font Size - DropdownMenu and input */}
<DropdownMenu>
<div className='flex items-center gap-[2px] h-[28px] rounded-[14px] hover:bg-accent transition-colors flex-shrink-0 focus-within:bg-accent'>
<input
type='number'
value={element.fontSize || 14}
min={1}
max={999}
className='w-[40px] bg-transparent border-none text-right outline-none text-[14px] text-foreground tabular-nums [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none'
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') e.currentTarget.blur()
}}
onChange={e => {
const value = parseInt(e.target.value)
if (!isNaN(value)) {
handleUpdate({ fontSize: value })
}
}}
/>
<span className='text-[14px] text-foreground leading-none pb-[3px] pr-2px'>
px
</span>
<DropdownMenuTrigger asChild>
<div className='pr-4px h-[28px] flex items-center cursor-pointer'>
<LocalIcon
name='dropdown'
className='w-[16px] h-[16px] text-foreground/40'
/>
</div>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent
side='bottom'
align='end'
className='max-h-[300px] min-w-[80px] tabular-nums'
>
{FONT_SIZES.map(s => (
<DropdownMenuItem
key={s}
onClick={() => handleUpdate({ fontSize: s })}
className='focus:bg-accent data-[highlighted]:bg-accent cursor-pointer'
>
<div className='flex items-center px-[4px] py-[2px] text-[14px] text-foreground'>
{s}px
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<div className='w-[1px] h-[16px] bg-border/60 flex-shrink-0' />
{/* Bold */}
<div
className={cn(
'flex items-center justify-center w-[28px] h-[28px] rounded-[14px] cursor-pointer transition-colors flex-shrink-0',
element.fontWeight === 'bold' || element.fontWeight === 700
? 'bg-muted text-foreground'
: 'text-foreground hover:bg-accent',
)}
onClick={() =>
handleUpdate({
fontWeight:
element.fontWeight === 'bold' || element.fontWeight === 700
? 'normal'
: 'bold',
})
}
>
<LocalIcon name='bold' className='w-[16px] h-[16px]' />
</div>
{/* Italic */}
<div
className={cn(
'flex items-center justify-center w-[28px] h-[28px] rounded-[14px] cursor-pointer transition-colors flex-shrink-0',
element.fontStyle === 'italic'
? 'bg-muted text-foreground'
: 'text-foreground hover:bg-accent',
)}
onClick={() =>
handleUpdate({
fontStyle: element.fontStyle === 'italic' ? 'normal' : 'italic',
})
}
>
<LocalIcon name='italic' className='w-[16px] h-[16px]' />
</div>
{/* Alignment Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className='flex items-center gap-[4px] pl-8px h-[28px] rounded-[14px] cursor-pointer hover:bg-accent transition-colors flex-shrink-0'>
{currentAlign?.icon()}
<LocalIcon
name='dropdown'
className='w-[16px] h-[16px] text-foreground/40'
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='end'>
{ALIGNMENTS.map(a => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdate({ textAlign: a.id as 'left' })}
className='focus:bg-accent data-[highlighted]:bg-accent cursor-pointer'
>
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
{a.icon()}
<span className='text-[14px] text-foreground whitespace-nowrap'>
{a.label}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* More Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div
className={cn(
'flex items-center justify-center w-[28px] h-[28px] rounded-[14px] cursor-pointer transition-colors flex-shrink-0',
'text-foreground hover:bg-accent',
)}
>
<LocalIcon
name='image-editor-text-more'
className='w-[16px] h-[16px]'
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='end'>
{LAYER.map(a => (
<DropdownMenuItem
key={a.id}
onClick={() => handleUpdateLayer(a.id)}
className='focus:bg-accent data-[highlighted]:bg-accent cursor-pointer'
>
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
{a.icon()}
<span className='text-[14px] text-foreground whitespace-nowrap'>
{a.label}
</span>
</div>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import React, { memo } from 'react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import { cn } from '@/utils/cn'
import { IconButton } from '../ui/icon'
import { useDominoInstance } from '../canvas'
import { useZoomActions } from '../../hooks/use-zoom-actions'
const ZOOM_LEVELS = [1]
export interface TopBarProps {
className?: string
rootRef: React.RefObject<HTMLDivElement | null>
onBack?: (e: React.MouseEvent) => void
onClose?: () => void
expand?: boolean
onToggleExpand?: (e: React.MouseEvent) => void
}
function TopBar(props: TopBarProps) {
const { className, rootRef, onBack, onClose, expand, onToggleExpand } = props
const { zoomToFit } = useDominoInstance()
const { handleZoom, stepZoom, scale } = useZoomActions(rootRef)
return (
<div
className={cn(
'domi-board-top-bar',
'flex justify-between absolute top-0 left-0 w-full px-[16px] py-[14px]',
'overflow-visible pointer-events-none [&>*]:pointer-events-auto',
className,
)}
>
<div className='flex items-center gap-[8px]'>
{/* {onBack && (
<IconButton
icon='back'
className='w-[32px] h-[32px] bg-[#fff]/90 border-[0.5px] border-[#E1E1E5]/80 rounded-full !hover:bg-[#f8f8fa]'
onClick={onBack}
/>
)} */}
</div>
<div className='flex justify-between gap-[8px] text-[var(--editor-text)]'>
{/* Zoom Controls */}
<div className='editor-floating-panel-soft flex items-center rounded-[100px] p-[4px]'>
<IconButton
icon='board-zoom-out'
className='rounded-full'
onClick={() => stepZoom(-1)}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className='editor-toolbar-chip flex h-[24px] min-w-[50px] cursor-pointer items-center justify-center rounded-[16px] px-[10px] text-[14px] transition-opacity'>
{Math.round(scale * 100)}%
</div>
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='center'>
{ZOOM_LEVELS.map(z => (
<DropdownMenuItem
key={z.toFixed(2)}
onClick={() => handleZoom(z)}
className='justify-center focus:bg-accent cursor-pointer'
>
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
<span className='text-[14px] text-foreground'>
{Math.round(z * 100)}%
</span>
</div>
</DropdownMenuItem>
))}
<DropdownMenuItem
onClick={() => zoomToFit()}
className='justify-center focus:bg-accent cursor-pointer'
>
<div className='flex items-center gap-[8px] px-[4px] py-[2px]'>
<span className='text-[14px] text-foreground'></span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<IconButton
icon='board-zoom-in'
className='rounded-full'
onClick={() => stepZoom(1)}
/>
</div>
<div className='editor-floating-panel-soft flex h-[32px] items-center gap-[5px] rounded-[100px] p-[4px] empty:hidden'>
{onToggleExpand && (
<IconButton
icon={expand ? 'zoom-in' : 'zoom-out'}
className='rounded-full'
onClick={onToggleExpand}
/>
)}
{/* {onClose && (
<IconButton
icon='board-close'
className='rounded-full'
onClick={onClose}
/>
)} */}
</div>
</div>
</div>
)
}
export default memo(TopBar)

View File

@@ -0,0 +1,57 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/utils/cn'
const buttonVariants = cva(
'box-border inline-flex items-center border-solid justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-[10px]',
sm: 'h-9 px-[15px]',
lg: 'h-11 px-[40px]',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends
React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,209 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { cn } from '@/utils/cn'
import { LocalIcon } from './local-icon'
function DropdownMenu(
props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>,
) {
return <DropdownMenuPrimitive.Root modal={false} {...props} />
}
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-[4px] px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent focus:text-accent-foreground data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className,
)}
{...props}
>
{children}
<LocalIcon name='chevron-right' className='ml-auto h-4 w-4' />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
'z-50 min-w-[8rem] overflow-auto overscroll-none rounded-[6px] border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
usePortal?: boolean
}
>(({ className, sideOffset = 4, usePortal = false, ...props }, ref) => {
const content = (
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 min-w-[8rem] overflow-auto overscroll-none rounded-[6px] border border-border bg-popover p-[4px] text-popover-foreground backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
)
return usePortal ? (
<DropdownMenuPrimitive.Portal>{content}</DropdownMenuPrimitive.Portal>
) : (
content
)
})
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset = true, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-pointer select-none items-center rounded-[4px] py-[6px] text-sm outline-none transition-colors hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-[8px]',
className,
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<LocalIcon name='check' className='h-4 w-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors hover:bg-accent focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<LocalIcon name='circle' size={8} className='fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className,
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn('-mx-1 my-1 h-px bg-border/70', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
function DropdownMenuShortcut({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,37 @@
import React, { Component, type ReactNode } from 'react'
interface Props {
children?: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
}
public static getDerivedStateFromError(_: Error): State {
return { hasError: true }
}
public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Uncaught error:', error, errorInfo)
}
public render() {
if (this.state.hasError) {
return (
this.props.fallback || (
<div className='flex items-center justify-center p-4 text-red-500'>
</div>
)
)
}
return this.props.children
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,108 @@
import React, { forwardRef } from 'react'
import type {
ForwardedRef,
DetailedHTMLProps,
HTMLAttributes,
ReactNode,
} from 'react'
import { cn } from '@/utils/cn'
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from './tooltip'
import { LocalIcon } from './local-icon'
export interface IconButtonProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
disabled?: boolean
disabledTip?: string
className?: string
icon?: string | ReactNode
iconSize?: string
size?: string
onClick?: (event: React.MouseEvent<HTMLSpanElement>) => void
children?: ReactNode
}
export const IconButton = forwardRef(function IconButton(
props: IconButtonProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const {
disabled,
disabledTip,
className,
icon,
iconSize = 'w-[16px] h-[16px]',
size = 'w-[24px] h-[24px]',
onClick,
children,
...rest
} = props
const content = (
<div
ref={ref}
className={cn(
'flex items-center justify-center rounded-[8px] duration-300',
size,
className,
{
'cursor-pointer text-[var(--editor-text)] hover:bg-accent': !disabled,
'cursor-not-allowed opacity-40': disabled,
},
)}
onClick={disabled ? undefined : onClick}
{...rest}
>
{typeof icon === 'string' ? (
<LocalIcon name={icon} className={iconSize} />
) : (
<div className={iconSize}>{icon}</div>
)}
{children}
</div>
)
if (!disabled || !disabledTip) {
return content
}
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>{disabledTip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
})
interface IconProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
icon?: string
size?: string
className?: string
onClick?: (event: React.MouseEvent<HTMLSpanElement>) => void
}
export const Icon = forwardRef(function Icon(
props: IconProps,
ref: ForwardedRef<HTMLDivElement>,
) {
const { icon, size = 'w-[16px] h-[16px]', className, ...rest } = props
return (
<div
ref={ref}
data-icon={icon}
className={cn('inline-flex items-center justify-center', size, className)}
{...rest}
>
<LocalIcon name={icon || ''} className='size-full' />
</div>
)
})

View File

@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '@/utils/cn'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-6 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = 'Input'
export { Input }

View File

@@ -0,0 +1,48 @@
import React from 'react'
import { cn } from '@/utils/cn'
import { icons } from './icon-mapping'
export interface LocalIconProps extends React.HTMLAttributes<HTMLSpanElement> {
name: string
size?: number | string
}
/**
* 本地图标组件,使用 mask 方案实现颜色控制
*/
export function LocalIcon({
name,
size = 16,
className,
...props
}: LocalIconProps) {
const svgContent = icons[`../../icons/${name}.svg`]
if (!svgContent) {
console.warn(`Icon ${name} not found in src/icons/`)
return null
}
const sizeStyle = typeof size === 'number' ? `${size}px` : size
// 将 SVG 原生内容转换为 data URI通过 encodeURIComponent
// 这样无论是在 SDK 独立开发中还是主站引入,都不会有路径找不到的跨域/路由问题
const maskUrl = `url("data:image/svg+xml;utf8,${encodeURIComponent(svgContent)}")`
return (
<span
className={cn('inline-block shrink-0', className)}
style={{
width: sizeStyle,
height: sizeStyle,
mask: `${maskUrl} no-repeat center`,
maskSize: 'contain',
WebkitMask: `${maskUrl} no-repeat center`,
WebkitMaskSize: 'contain',
backgroundColor: 'currentColor',
}}
{...props}
/>
)
}

View File

@@ -0,0 +1,39 @@
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/utils/cn'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipArrow = TooltipPrimitive.Arrow
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, children, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 rounded-md bg-black px-[10px] py-[6px] text-sm text-white shadow-md',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className='fill-black' />
</TooltipPrimitive.Content>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
TooltipArrow,
}

View File

@@ -0,0 +1,6 @@
export const MB = 1024 * 1024
export const MAX_IMAGE_SIZE_BYTES = 20 * MB
export const NEED_COMPRESS_MIN_SIZE = 3 * MB
export const NEED_COMPRESS_MAX_SIZE = 10 * MB
export const MATTING_MIN_DIM = 32
export const MATTING_MAX_DIM = 4000

View File

@@ -0,0 +1,265 @@
import type {
SceneElement,
ImageElement,
TextElement,
} from '../components/canvas'
/**
* 直接加载图片(不做跨域处理)
*/
function loadImageDirectly(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error(`Failed to load image: ${src}`))
img.src = src
})
}
/**
* 加载图片(通过 fetch 获取 blob避免跨域污染 canvas
* 对于跨域图片,先 fetch 为 blob再创建 object URL
*/
async function loadImage(src: string): Promise<HTMLImageElement> {
// 检查是否是 data URL 或 blob URL这些不需要特殊处理
if (src.startsWith('data:') || src.startsWith('blob:')) {
return loadImageDirectly(src)
}
// 检查是否是同源
try {
const url = new URL(src, window.location.origin)
if (url.origin === window.location.origin) {
// 同源图片直接加载
return loadImageDirectly(src)
}
} catch {
// URL 解析失败,尝试直接加载
return loadImageDirectly(src)
}
// 跨域图片:通过 fetch 获取 blob
try {
const response = await fetch(src, { mode: 'cors' })
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`)
}
const blob = await response.blob()
const blobUrl = URL.createObjectURL(blob)
try {
const img = await loadImageDirectly(blobUrl)
// 注意:这里不立即 revoke因为 canvas 可能还需要使用
// 在 exportFrameAsCanvas 完成后会释放
return img
} catch (err) {
URL.revokeObjectURL(blobUrl)
throw err
}
} catch {
// fetch 失败,回退到直接加载(可能仍会污染 canvas
console.warn(
`[Export] Failed to fetch image via blob, falling back to direct load: ${src}`,
)
return loadImageDirectly(src)
}
}
/**
* 绘制单行文字
*/
function drawTextLine(
ctx: CanvasRenderingContext2D,
line: string,
boxX: number,
y: number,
maxWidth: number,
textAlign: string,
) {
let x = boxX
if (textAlign === 'center') {
x = boxX + maxWidth / 2
ctx.textAlign = 'center'
} else if (textAlign === 'right') {
x = boxX + maxWidth
ctx.textAlign = 'right'
} else {
ctx.textAlign = 'left'
}
ctx.fillText(line, x, y)
}
/**
* 绘制文本元素并处理换行
*/
function drawTextElement(
ctx: CanvasRenderingContext2D,
element: TextElement,
boxX: number,
boxY: number,
) {
const {
content,
fontSize = 32,
fontFamily = 'Inter, sans-serif',
color = '#000000',
textAlign = 'left',
fontWeight = 'normal',
fontStyle = 'normal',
width: maxWidth,
} = element
ctx.font = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`
ctx.fillStyle = color
ctx.textBaseline = 'top'
const lineHeight = fontSize * 1.2
const paragraphs = (content || '').split('\n')
let currentY = boxY
for (const paragraph of paragraphs) {
if (paragraph === '') {
currentY += lineHeight
continue
}
// 字符级换行,匹配 break-words 逻辑
const chars = paragraph.split('')
let currentLine = ''
for (let n = 0; n < chars.length; n++) {
const testLine = currentLine + chars[n]
const metrics = ctx.measureText(testLine)
const testWidth = metrics.width
if (testWidth > maxWidth && n > 0 && currentLine !== '') {
drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign)
currentLine = chars[n]
currentY += lineHeight
} else {
currentLine = testLine
}
}
drawTextLine(ctx, currentLine, boxX, currentY, maxWidth, textAlign)
currentY += lineHeight
}
}
export function useDownLoadImageGroup(
selectionBounds: {
left: number
top: number
width: number
height: number
} | null,
elements: Array<SceneElement>,
) {
const { left = 0, top = 0, width = 0, height = 0 } = selectionBounds ?? {}
const scale = 2 // 放大倍数,提升图片质量
const drawCanvas = async () => {
// 创建 canvas
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
canvas.width = width * scale
canvas.height = height * scale
ctx.scale(scale, scale)
// 填充白色背景
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
// 渲染子元素
for (const element of elements) {
if (!element) continue
const { x, y, width: elWidth, height: elHeight, rotation = 0 } = element
const boxX = x - left
const boxY = y - top
ctx.save()
// 旋转处理
if (rotation !== 0) {
ctx.translate(boxX + elWidth / 2, boxY + elHeight / 2)
ctx.rotate((rotation * Math.PI) / 180)
ctx.translate(-(boxX + elWidth / 2), -(boxY + elHeight / 2))
}
if (element.type === 'image') {
const img = await loadImage((element as ImageElement).src)
ctx.drawImage(img, boxX, boxY, elWidth, elHeight)
} else if (element.type === 'text') {
drawTextElement(ctx, element as TextElement, boxX, boxY)
}
ctx.restore()
}
return canvas
}
const downloadCompositeImage = async () => {
const canvas = await drawCanvas()
if (!canvas) return
// 生成图片并下载
canvas.toBlob(
blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `composite-${Date.now()}.jpg`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
},
'image/jpeg',
0.95,
)
}
const downloadImage = async (
src: string,
fileName: string,
useCanvas = false,
) => {
if (useCanvas) {
const img = await loadImage(src)
// 生成图片并下载
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.drawImage(img, 0, 0)
canvas.toBlob(
blob => {
if (!blob) return
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName || 'image.jpeg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
},
'image/jpeg',
0.95,
)
} else {
// 直接使用a标签下载图片
const link = document.createElement('a')
link.href = src
link.download = fileName || 'image.jpeg'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
return { downloadCompositeImage, downloadImage }
}

View File

@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { toast } from 'sonner'
import {
type ImageElement,
type SceneElement,
type TextStyle,
useDominoStoreInstance,
} from '../components/canvas'
import { MAX_IMAGE_SIZE_BYTES } from '../consts'
import {
getImageDimension,
isValidImageFormat,
uploadImage,
waitForElement,
} from '../utils/helper'
export function useElementActions(
taskId: string,
addElementToFlow: (elementTemplate: SceneElement) => SceneElement,
scrollIntoView?: (elementId: string) => void,
) {
const store = useDominoStoreInstance()
const lastTextStyleRef = useRef<TextStyle>({
fontSize: 48,
color: '#000000',
fontFamily: undefined,
fontWeight: 'normal',
fontStyle: 'normal',
textAlign: 'left',
})
useEffect(() => {
let lastSelection = store.getState().selectedIds
return store.subscribe(state => {
const selectedIds = state.selectedIds
if (selectedIds === lastSelection) return
lastSelection = selectedIds
const lastId = selectedIds[selectedIds.length - 1]
const el = state.elements[lastId]
if (el?.type === 'text') {
const textEl = el
lastTextStyleRef.current = {
fontSize: textEl.fontSize,
fontWeight: textEl.fontWeight,
color: textEl.color,
fontFamily: textEl.fontFamily,
lineHeight: textEl.lineHeight,
textAlign: textEl.textAlign,
fontStyle: textEl.fontStyle,
}
}
})
}, [store])
const handleAddArtboard = useCallback(() => {
const id = uuidv4()
addElementToFlow({
id,
type: 'artboard',
x: 0,
y: 0,
width: 750,
height: 1750,
rotation: 0,
originalWidth: 750,
originalHeight: 1750,
childrenIds: [],
background: '#FFFFFF',
lockAspectRatio: false,
})
scrollIntoView?.(id)
}, [addElementToFlow, scrollIntoView])
const handleAddText = useCallback(
(options?: { content?: string; style?: Partial<TextStyle> }) => {
const id = uuidv4()
const lastTextStyle = lastTextStyleRef.current
const addedElement = addElementToFlow({
id,
type: 'text',
fontSize: 48,
x: 0,
y: 0,
width: 0,
height: 100,
rotation: 0,
originalWidth: 200,
originalHeight: 100,
content: options?.content || 'New Text',
resize: 'horizontal',
...lastTextStyle,
...options?.style,
})
scrollIntoView?.(id)
// 等待 DOM 渲染后再选中,确保能获取到正确的元素尺寸
waitForElement(id).then(() => {
store.getState().setSelectedIds([id])
})
return addedElement
},
[addElementToFlow, scrollIntoView, store],
)
const handleAddImageFromFile = useCallback(
async (file: File) => {
const { name, type } = file
const isImage = type.startsWith('image/')
if (!isImage && !isValidImageFormat(name)) {
toast.error('不合法的图片格式')
return
}
if (file.size >= MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
const objectUrl = URL.createObjectURL(file)
const { removeElement, addElement, updateElementUIState } =
store.getState()
const tempId = uuidv4()
try {
const { width, height } = await getImageDimension(objectUrl)
// Optimistic update
const optimisticImage: ImageElement = {
id: tempId,
type: 'image',
x: 0,
y: 0,
width,
height,
rotation: 0,
src: objectUrl,
fileName: name,
originalWidth: width,
originalHeight: height,
data: {
size: file.size,
},
}
const addedElement = addElementToFlow(optimisticImage) as ImageElement
updateElementUIState(tempId, {
status: 'pending',
statusText: '处理中...',
})
scrollIntoView?.(tempId)
let url = objectUrl
let file_path = ''
let size = file.size
try {
// Upload
const res = await uploadImage(taskId, file)
url = res.url
file_path = res.file_path
size = res.size
} catch (e) {
console.warn('Preload failed', e)
}
// Replace optimistic image with permanent one
removeElement(tempId)
const newImage: ImageElement = {
...addedElement,
id: file_path,
src: url,
data: {
path: file_path,
size,
},
}
addElement(newImage)
updateElementUIState(file_path, {
status: 'idle',
})
store.getState().setFocusedElementId(file_path)
scrollIntoView?.(file_path)
if (url !== objectUrl) {
URL.revokeObjectURL(objectUrl)
}
} catch (error) {
console.error('Failed to add image:', error)
removeElement(tempId)
toast.error('添加失败')
URL.revokeObjectURL(objectUrl)
}
},
[taskId, addElementToFlow, scrollIntoView, store],
)
const handleAddImage = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.onchange = async e => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
handleAddImageFromFile(file)
}
input.click()
}, [handleAddImageFromFile])
const handleDeleteElements = useCallback(
async (ids: string[]) => {
if (ids.length === 0) return
const { removeElement, takeSnapshot } = store.getState()
takeSnapshot()
ids.forEach(id => removeElement(id))
},
[store],
)
return {
handleAddArtboard,
handleAddImage,
handleAddImageFromFile,
handleAddText,
handleDeleteElements,
}
}

View File

@@ -0,0 +1,436 @@
import { useCallback } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { toast } from 'sonner'
import {
imageMatting,
imageOCR,
recognizeImage,
updateImageOCRText,
segmentLayer,
getSegmentLayerResult,
} from '../service/api'
import type {
ImageOCRResponse,
ImageOCRTextUpdateItem,
RecognizedImageElement,
} from '../service/type'
import {
type ImageElement,
type PlaceholderElement,
type SceneElement,
type ArtboardElement,
useDominoStoreInstance,
} from '../components/canvas'
import {
MAX_IMAGE_SIZE_BYTES,
MATTING_MIN_DIM,
MATTING_MAX_DIM,
NEED_COMPRESS_MAX_SIZE,
NEED_COMPRESS_MIN_SIZE,
} from '../consts'
import {
getImageFileSize,
getImageDimension,
getImageUrl,
isValidImageFormat,
isValidImageSize,
manualPersistence,
} from '../utils/helper'
export function useImageEditActions(
taskId: string,
addElementToFlow: (elementTemplate: SceneElement) => SceneElement,
onPartialRedraw?: (
element: ImageElement,
recognizedElements: readonly RecognizedImageElement[],
) => void,
) {
const store = useDominoStoreInstance()
const handleMatting = useCallback(
async (element: ImageElement) => {
const { originalWidth: w, originalHeight: h } = element
// 1. Dimension check
if (w < MATTING_MIN_DIM || h < MATTING_MIN_DIM) {
toast.error('图片尺寸不能小于 32x32px')
return
}
if (w > MATTING_MAX_DIM || h > MATTING_MAX_DIM) {
toast.error('图片尺寸不能超过 4000x4000px')
return
}
// 2. Size check
const { updateElement } = store.getState()
const fileSize = await getImageFileSize(element)
if (!element.data?.size && fileSize > 0) {
updateElement(element.id, { data: { ...element.data, size: fileSize } })
}
if (fileSize > MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
const placeholderId = uuidv4()
const { removePlaceholder, updateElementUIState, addElement } =
store.getState()
// Immediately insert placeholder element for instant feedback
const placeholder: PlaceholderElement = {
id: placeholderId,
type: 'placeholder',
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.width,
originalHeight: element.height,
label: '生成中',
}
const addedPlaceholder = addElementToFlow(
placeholder,
) as PlaceholderElement
updateElementUIState(placeholderId, { status: 'pending' })
try {
const res = await imageMatting({
image_url: element.data?.path || element.id,
task_id: taskId,
compress: fileSize > NEED_COMPRESS_MIN_SIZE,
})
try {
// Preload image
const url = await getImageUrl(taskId, res.path)
const { width, height } = await getImageDimension(url)
// Replace placeholder with matting result
removePlaceholder(placeholderId)
const newImage: ImageElement = {
...addedPlaceholder,
id: res.path,
type: 'image',
src: url,
width,
height,
fileName: res.path.split('/').pop() || 'image.jpg',
originalWidth: width,
originalHeight: height,
data: {
path: res.path,
},
}
addElement(newImage)
updateElementUIState(res.path, { status: 'idle' })
// Support background task persistence
manualPersistence(taskId, store)
} catch (e) {
console.warn('Preload failed', e)
}
} catch (error) {
console.error(error)
removePlaceholder(placeholderId)
}
},
[taskId, addElementToFlow, store],
)
const handlePartialRedraw = useCallback(
async (element: ImageElement) => {
if (!isValidImageFormat(element.fileName)) {
toast.error('不合法的图片格式')
return
}
if (!isValidImageSize(element.originalWidth, element.originalHeight)) {
toast.error('图片尺寸不能超过 2160x3840px 或 3840x2160px')
return
}
const { updateElement, updateElementUIState } = store.getState()
const fileSize = await getImageFileSize(element)
if (!element.data?.size && fileSize > 0) {
updateElement(element.id, { data: { ...element.data, size: fileSize } })
}
if (fileSize >= MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
updateElementUIState(element.id, {
status: 'pending',
statusText: '识图中...',
})
try {
const res = await recognizeImage({
image_url: element.data?.path || element.id,
task_id: taskId,
width: element.originalWidth,
height: element.originalHeight,
})
onPartialRedraw?.(element, res || [])
} catch (err) {
console.error('Failed to recognize image:', err)
} finally {
updateElementUIState(element.id, {
status: 'idle',
})
}
},
[taskId, onPartialRedraw, store],
)
const handleEditText = useCallback(
async (
element: ImageElement,
onOCRSuccess: (data: ImageOCRResponse) => void,
) => {
if (!isValidImageFormat(element.fileName)) {
toast.error('不合法的图片格式')
return
}
if (!isValidImageSize(element.originalWidth, element.originalHeight)) {
toast.error('图片尺寸不能超过 2160x3840px 或 3840x2160px')
return
}
const { updateElement, updateElementUIState } = store.getState()
const fileSize = await getImageFileSize(element)
if (!element.data?.size && fileSize > 0) {
updateElement(element.id, { data: { ...element.data, size: fileSize } })
}
if (fileSize >= MAX_IMAGE_SIZE_BYTES) {
toast.error('图片大小不能超过 20MB')
return
}
updateElementUIState(element.id, {
status: 'pending',
statusText: '正在提取文字',
})
const errorText = '没有提取到文字'
try {
const data = await imageOCR({
image_url: element.data?.path || element.id,
task_id: taskId,
})
if (data.length > 0) {
onOCRSuccess(data)
} else {
toast.error(errorText)
}
} catch (error) {
console.error(error)
toast.error(errorText)
} finally {
updateElementUIState(element.id, { status: 'idle' })
}
},
[taskId, store],
)
const handleConfirmEditText = useCallback(
async (element: ImageElement, updatedList: ImageOCRTextUpdateItem[]) => {
const placeholderId = uuidv4()
const { removePlaceholder, updateElementUIState, addElement } =
store.getState()
// Insert new placeholder element into the flow
const placeholder: PlaceholderElement = {
id: placeholderId,
type: 'placeholder',
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.width,
originalHeight: element.height,
label: '处理中...',
}
const addedPlaceholder = addElementToFlow(
placeholder,
) as PlaceholderElement
updateElementUIState(placeholderId, { status: 'pending' })
try {
const res = await updateImageOCRText({
image_url: element.data?.path || element.id,
texts: updatedList,
task_id: taskId,
compress: (element.data?.size ?? 0) > NEED_COMPRESS_MAX_SIZE,
})
// Preload next image
try {
const url = await getImageUrl(taskId, res.path)
const { width, height } = await getImageDimension(url)
// Replace placeholder with result
removePlaceholder(placeholderId)
const newImage: ImageElement = {
...addedPlaceholder,
id: res.path,
type: 'image',
src: url,
width,
height,
fileName: res.path.split('/').pop() || 'image.jpg',
originalWidth: width,
originalHeight: height,
data: {
path: res.path,
},
}
addElement(newImage)
updateElementUIState(res.path, { status: 'idle' })
// Support background task persistence
manualPersistence(taskId, store)
} catch (e) {
console.warn('Preload failed', e)
}
} catch (error) {
console.error(error)
removePlaceholder(placeholderId)
toast.error('修改失败')
}
},
[taskId, addElementToFlow, store],
)
const handleEditElements = useCallback(
async (element: ImageElement) => {
const { updateElementUIState, addElement, setSelectedIds } =
store.getState()
updateElementUIState(element.id, {
status: 'pending',
statusText: '正在分层...',
})
try {
const taskRecordId = await segmentLayer({
image_url: element.data?.path || element.id,
task_id: taskId,
width: element.originalWidth,
height: element.originalHeight,
})
// Polling loop
let task = await getSegmentLayerResult(taskRecordId)
while (task.status === 'PROCESSING') {
await new Promise(resolve => setTimeout(resolve, 2000))
task = await getSegmentLayerResult(taskRecordId)
}
if (
task.status === 'TIMEOUT' ||
task.status === 'FAILED' ||
!task.layers
) {
throw new Error('分层任务执行失败')
}
const layersData = task.layers
// 1. Prepare all layers (Sorted by layer_order from server)
const allLayersData = layersData.map(layer => ({
image: layer.image,
line_rect: {
x: layer.x,
y: layer.y,
width: layer.width,
height: layer.height,
},
line_text: layer.desc,
hideMetadata: layer.desc === 'background',
}))
// 2. Preload all layers and prepare IDs
const layers = await Promise.all(
allLayersData.map(async item => {
const itemUrl = await getImageUrl(taskId, item.image.path)
const childId = uuidv4()
return { item, itemUrl, childId }
}),
)
// 3. Create Artboard Template
const artboardId = uuidv4()
const artboardTemplate: ArtboardElement = {
id: artboardId,
type: 'artboard',
name: `画板 - ${element.fileName}`,
x: 0,
y: 0,
width: element.width,
height: element.height,
rotation: element.rotation || 0,
originalWidth: element.originalWidth,
originalHeight: element.originalHeight,
background: '#FFFFFF',
childrenIds: layers.map(l => l.childId),
}
// Add artboard to flow
const addedArtboard = addElementToFlow(
artboardTemplate,
) as ArtboardElement
// 4. Add all child elements to store
for (const layer of layers) {
const { item, itemUrl, childId } = layer
const childImage: ImageElement = {
id: childId,
type: 'image',
x: item.line_rect.x,
y: item.line_rect.y,
width: item.line_rect.width,
height: item.line_rect.height,
rotation: 0,
originalWidth: item.line_rect.width,
originalHeight: item.line_rect.height,
src: itemUrl,
fileName: item.image.path.split('/').pop() || 'layer.png',
parentId: artboardId,
selectable: true,
hideMetadata: item.hideMetadata,
data: {
path: item.image.path,
text: item.line_text,
},
}
addElement(childImage)
}
setSelectedIds([addedArtboard.id])
// Support background task persistence
manualPersistence(taskId, store)
} catch (err) {
console.error('Failed to segment text layer:', err)
toast.error('修改失败')
} finally {
updateElementUIState(element.id, {
status: 'idle',
statusText: undefined,
})
}
},
[taskId, store, addElementToFlow],
)
return {
handleMatting,
handlePartialRedraw,
handleEditText,
handleConfirmEditText,
handleEditElements,
}
}

View File

@@ -0,0 +1,162 @@
import { useCallback } from 'react'
import { useDominoStoreInstance } from '../components/canvas'
import type {
SceneElement,
PlaceholderElement,
Padding,
} from '../components/canvas'
export interface LayoutOptions {
maxWidth?: number
padding?: Padding
colGap?: number
rowGap?: number
}
export function useLayout(options: LayoutOptions = {}) {
const {
maxWidth = 7000,
padding = { top: 100, left: 100, bottom: 100, right: 100 },
colGap = 20,
rowGap = 80,
} = options
const store = useDominoStoreInstance()
const topPadding = padding.top ?? 0
const leftPadding = padding.left ?? 0
/**
* Rearrange all existing top-level elements
*/
const arrangeElements = useCallback(
(overrideOptions?: LayoutOptions) => {
const config = {
maxWidth,
padding,
colGap,
rowGap,
...overrideOptions,
}
const state = store.getState()
const { elements, placeholders, elementOrder } = state
const configTop = config.padding?.top ?? topPadding
const configLeft = config.padding?.left ?? leftPadding
// Record history
store.getState().takeSnapshot()
const newPositions: Record<
string,
{ x: number; y: number; rotation: number }
> = {}
let currentX = configLeft
let currentY = configTop
let maxHeightInRow = 0
const elementsToArrange = elementOrder
.map((id: string) => elements[id] || placeholders[id])
.filter(
(el: SceneElement | undefined): el is SceneElement =>
el !== undefined,
)
elementsToArrange.forEach((el: SceneElement) => {
if (currentX + el.width > config.maxWidth) {
currentX = configLeft
currentY += maxHeightInRow + config.rowGap
maxHeightInRow = 0
}
newPositions[el.id] = {
x: currentX,
y: currentY,
rotation: 0,
}
maxHeightInRow = Math.max(maxHeightInRow, el.height)
currentX += el.width + config.colGap
})
store.setState(
(state: {
elements: Record<string, SceneElement>
placeholders: Record<string, PlaceholderElement>
}) => {
Object.entries(newPositions).forEach(([id, pos]) => {
if (state.elements[id]) {
state.elements[id] = { ...state.elements[id], ...pos }
} else if (state.placeholders[id]) {
state.placeholders[id] = { ...state.placeholders[id], ...pos }
}
})
},
)
},
[maxWidth, topPadding, leftPadding, colGap, rowGap, store, padding],
)
/**
* Add a new element to the end of the flow
*/
const addElementToFlow = useCallback(
<T extends SceneElement>(elementTemplate: T): T => {
const state = store.getState()
const { elements, placeholders, elementOrder } = state
const getAnyElement = (id: string) => elements[id] || placeholders[id]
// Get all top-level elements to find the current "flow" tail
const existingElements = elementOrder
.map((id: string) => getAnyElement(id))
.filter(
(el: SceneElement | undefined): el is SceneElement =>
!!el &&
(el.type !== 'artboard' ||
(el.type === 'artboard' && !el.parentId)),
)
const lastElement = existingElements[existingElements.length - 1]
let currentX = lastElement
? lastElement.x + lastElement.width + colGap
: leftPadding
let currentY = lastElement ? lastElement.y : topPadding
// To find the current row's max height, we look at elements on the same Y
const currentRowElements = existingElements.filter(
(el: SceneElement) => el.y === currentY,
)
const maxHeightInRow =
currentRowElements.length > 0
? Math.max(...currentRowElements.map((el: SceneElement) => el.height))
: 0
// Wrap check
if (currentX + elementTemplate.width > maxWidth) {
currentX = leftPadding
currentY += maxHeightInRow + rowGap
}
const newElement: SceneElement = {
...elementTemplate,
x: currentX,
y: currentY,
}
if (newElement.type === 'placeholder') {
state.addPlaceholder(newElement as PlaceholderElement)
} else {
state.addElement(newElement)
}
state.setFocusedElementId(newElement.id)
state.setSelectedIds([])
return newElement as T
},
[colGap, rowGap, maxWidth, topPadding, leftPadding, store],
)
return { arrangeElements, addElementToFlow }
}

View File

@@ -0,0 +1,83 @@
import { useState, useEffect } from 'react'
export interface FontOption {
label: string
value: string
}
const DEFAULT_FONTS: FontOption[] = [
{ label: '黑体', value: 'SimHei, sans-serif' },
{ label: '宋体', value: 'SimSun, serif' },
{
label: '微软雅黑',
value: 'Microsoft YaHei, sans-serif',
},
{ label: 'PingFang SC', value: 'PingFang SC, sans-serif' },
{ label: 'Inter', value: 'Inter, sans-serif' },
]
let cachedFonts: FontOption[] | null = null
export function useLocalFonts() {
const [fonts, setFonts] = useState<FontOption[]>(cachedFonts || DEFAULT_FONTS)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (cachedFonts && cachedFonts.length > DEFAULT_FONTS.length) {
return
}
async function loadLocalFonts() {
// Check if the API is supported
if (!('queryLocalFonts' in window)) {
return
}
try {
setLoading(true)
// @ts-expect-error - queryLocalFonts is a new API
const localFonts = await window.queryLocalFonts()
// Group by family and take the first one (usually the regular style)
// or just use unique families
const families = new Set<string>()
const dynamicFonts: FontOption[] = []
localFonts.forEach((font: any) => {
if (!families.has(font.family)) {
families.add(font.family)
dynamicFonts.push({
label: font.family,
value: font.family,
})
}
})
// Sort alphabetically
dynamicFonts.sort((a, b) => a.label.localeCompare(b.label))
// Combine with defaults, ensuring no duplicates by family name
const combined = [...DEFAULT_FONTS]
const defaultFamilies = new Set(DEFAULT_FONTS.map(f => f.label))
dynamicFonts.forEach(df => {
if (!defaultFamilies.has(df.label)) {
combined.push(df)
}
})
cachedFonts = combined
setFonts(combined)
} catch (err) {
console.error('Failed to query local fonts:', err)
// Fallback is already set as initial state
} finally {
setLoading(false)
}
}
loadLocalFonts()
}, [])
return { fonts, loading }
}

View File

@@ -0,0 +1,80 @@
import { useCallback } from 'react'
interface UsePasteHandlerProps {
readOnly: boolean
handleAddImageFromFile: (file: File) => Promise<void>
handleAddText: (options?: {
content?: string
style?: Record<string, any>
}) => void
}
export function usePasteHandler({
readOnly,
handleAddImageFromFile,
handleAddText,
}: UsePasteHandlerProps) {
const handlePaste = useCallback(
async (e: ClipboardEvent) => {
if (readOnly) return
// 1. 尝试解析图片
const items = e.clipboardData?.items
if (items) {
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile()
if (file) {
e.preventDefault()
await handleAddImageFromFile(file)
return
}
}
}
}
// 2. 尝试解析带有样式的文本元素或纯文本
const textHtml = e.clipboardData?.getData('text/html')
const textPlain = e.clipboardData?.getData('text/plain')
if (textHtml) {
const match = textHtml.match(/<!--DOMINO_TEXT_ELEMENT:(.*?)-->/)
if (match) {
try {
const data = JSON.parse(match[1])
if (data.type === 'domino-text-element') {
e.preventDefault()
handleAddText({
content: data.content,
style: {
fontSize: data.fontSize,
fontWeight: data.fontWeight,
color: data.color,
fontFamily: data.fontFamily,
lineHeight: data.lineHeight,
textAlign: data.textAlign,
fontStyle: data.fontStyle,
width: data.width,
height: data.height,
},
})
return
}
} catch (err) {
console.error('Failed to parse pasted text element', err)
}
}
}
if (textPlain && textPlain.trim()) {
e.preventDefault()
handleAddText({ content: textPlain })
}
},
[readOnly, handleAddImageFromFile, handleAddText],
)
return handlePaste
}

View File

@@ -0,0 +1,156 @@
import { useEffect, useCallback, useRef, useState } from 'react'
import { shallow } from 'zustand/shallow'
import {
saveDomiBoardContent,
getDomiBoardContent,
} from '../service/api'
import { useDominoStoreInstance } from '../components/canvas'
import type { SceneElement, DominoCanvasData } from '../components/canvas'
export function usePersistence(options: {
taskId: string
setLoading: (loading: boolean) => void
}) {
const { taskId, setLoading } = options
const store = useDominoStoreInstance()
const isFirstLoad = useRef(true)
const [initialized, setInitialized] = useState(false)
// 任务切换时重置状态
useEffect(() => {
isFirstLoad.current = true
setInitialized(false)
}, [taskId])
const loadBoard = useCallback(async () => {
if (!taskId) {
setLoading(false)
setInitialized(true)
return
}
const currentTaskId = taskId
setLoading(true)
try {
const res = (await getDomiBoardContent(taskId)) as
| DominoCanvasData
| string
if (taskId !== currentTaskId) return
if (res) {
try {
const parsed = (
typeof res === 'string' ? JSON.parse(res) : res
) as DominoCanvasData
if (parsed && typeof parsed === 'object') {
const { elements, elementOrder, createdAt, updatedAt } = parsed
const state = store.getState()
state.clearElements()
Object.values(elements).forEach(el => {
state.addElement(el)
})
// addElement might have added them in different order, so we overwrite it.
store.setState({
elementOrder,
metadata: {
createdAt: createdAt || Date.now(),
updatedAt: updatedAt || Date.now(),
},
})
state.resetHistory()
setLoading(false)
isFirstLoad.current = false
setInitialized(true)
}
} catch (e) {
console.error('Failed to parse saved DomiBoard content', e)
}
}
} catch (e) {
console.error('Failed to load DomiBoard content', e)
} finally {
if (taskId === currentTaskId) {
if (isFirstLoad.current) {
isFirstLoad.current = false
}
setLoading(false)
setInitialized(true)
store.getState().resetHistory()
}
}
}, [taskId, setLoading])
const saveTimerRef = useRef<NodeJS.Timeout | null>(null)
// Auto-save logic
useEffect(() => {
let lastState = {
elements: store.getState().elements,
elementOrder: store.getState().elementOrder,
}
const unsubscribe = store.subscribe(state => {
const curr = {
elements: state.elements,
elementOrder: state.elementOrder,
}
// Check if relevant state changed using shallow comparison
if (shallow(lastState, curr)) return
lastState = curr
if (isFirstLoad.current || !taskId) return
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
saveTimerRef.current = setTimeout(async () => {
try {
const currentMetadata = store.getState().metadata || {}
const createdAt = currentMetadata.createdAt || Date.now()
const persistentElements: Record<string, SceneElement> = {}
const persistentOrder: string[] = []
Object.values(curr.elements).forEach(el => {
if (el.type !== 'placeholder') {
persistentElements[el.id] = el
}
})
curr.elementOrder.forEach((id: string) => {
if (curr.elements[id] && curr.elements[id].type !== 'placeholder') {
persistentOrder.push(id)
}
})
const persistenceData: DominoCanvasData = {
elements: persistentElements,
elementOrder: persistentOrder,
createdAt,
updatedAt: Date.now(),
}
await saveDomiBoardContent({
task_id: taskId,
data: persistenceData,
})
} catch (e) {
console.error('Failed to auto-save DomiBoard content', e)
}
}, 2000) // 2 second debounce
})
return () => {
unsubscribe()
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current)
}
}
}, [taskId, store])
return { loadBoard, initialized }
}

View File

@@ -0,0 +1,200 @@
import { useEffect } from 'react'
import type React from 'react'
import { toast } from 'sonner'
import {
useDominoStore,
useDominoStoreInstance,
type ImageElement,
type TextElement,
} from '../components/canvas'
import {
copyImageToClipboard,
copyTextElementToClipboard,
} from '../utils/helper'
import { useZoomActions } from './use-zoom-actions'
export interface UseShortcutsProps {
rootRef: React.RefObject<HTMLDivElement | null>
onDelete?: (ids: string[]) => void
onPaste?: (e: ClipboardEvent) => void
}
export function useShortcuts({ rootRef, onDelete, onPaste }: UseShortcutsProps) {
const store = useDominoStoreInstance()
const { stepZoom } = useZoomActions(rootRef)
const mode = useDominoStore(s => s.mode)
const setMode = useDominoStore(s => s.setMode)
const setViewport = useDominoStore(s => s.setViewport)
const undo = useDominoStore(s => s.undo)
const redo = useDominoStore(s => s.redo)
const selectedIds = useDominoStore(s => s.selectedIds)
const focusedElementId = useDominoStore(s => s.focusedElementId)
const readOnly = useDominoStore(s => s.readOnly)
useEffect(() => {
const isInputActive = () => {
// 1. 如果有元素正在被聚焦(比如正在编辑文字),禁用快捷键
if (focusedElementId) {
const { elements } = store.getState()
const focusedEl = elements[focusedElementId]
if (focusedEl?.type === 'text') return true
}
// 2. 如果原生 DOM 的输入框处于激活状态,禁用快捷键
const activeElement = document.activeElement
if (!activeElement) return false
return (
activeElement.tagName.toLowerCase() === 'input' ||
activeElement.tagName.toLowerCase() === 'textarea' ||
(activeElement as HTMLElement).isContentEditable
)
}
const handleKeyDown = (e: KeyboardEvent) => {
if (isInputActive()) return
const isMod = e.metaKey || e.ctrlKey
const isShift = e.shiftKey
// 1. Space -> Pan Mode
if (e.code === 'Space' && mode !== 'pan') {
e.preventDefault()
setMode('pan')
}
// 2. Undo/Redo
if (isMod && e.key.toLowerCase() === 'z') {
e.preventDefault()
if (isShift) redo()
else undo()
} else if (isMod && e.key.toLowerCase() === 'y') {
e.preventDefault()
redo()
}
// 3. Zoom Shortcuts
if (isMod && (e.key === '=' || e.key === '+')) {
e.preventDefault()
stepZoom(1)
}
if (isMod && (e.key === '-' || e.key === '_')) {
e.preventDefault()
stepZoom(-1)
}
if (e.key === '0') {
e.preventDefault()
setViewport({ scale: 1 })
}
// 4. Delete
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.length > 0 && !readOnly) {
e.preventDefault()
onDelete?.(selectedIds)
}
}
// 5. Select All
if (isMod && e.key.toLowerCase() === 'a') {
e.preventDefault()
const { elements, setSelectedIds } = store.getState()
setSelectedIds(Object.keys(elements))
}
// 6. Escape
if (e.key === 'Escape') {
const { setSelectedIds, setFocusedElementId } = store.getState()
setSelectedIds([])
setFocusedElementId(null)
if (mode !== 'select') setMode('select')
}
// 7. Arrow Keys
const ARROW_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']
if (ARROW_KEYS.includes(e.key) && selectedIds.length > 0 && !readOnly) {
e.preventDefault()
const step = isShift ? 10 : 1
const dx =
e.key === 'ArrowLeft' ? -step : e.key === 'ArrowRight' ? step : 0
const dy =
e.key === 'ArrowUp' ? -step : e.key === 'ArrowDown' ? step : 0
const { moveElements } = store.getState()
moveElements(selectedIds, dx, dy)
}
// 8. Copy
if (isMod && e.key.toLowerCase() === 'c') {
const { elements } = store.getState()
const selectedElements = selectedIds
.map(id => elements[id])
.filter(Boolean)
if (selectedElements.length === 0) return
e.preventDefault()
if (selectedElements.length === 1) {
const el = selectedElements[0]
if (el.type === 'text') {
const textEl = el as TextElement
copyTextElementToClipboard(textEl).then(success => {
if (success) toast.success('已复制文本')
})
} else if (el.type === 'image') {
const imageEl = el as ImageElement
copyImageToClipboard(imageEl.id).then(success => {
if (success) toast.success('已复制图片')
else toast.error('复制失败')
})
}
return
}
const texts = selectedElements
.filter((el): el is TextElement => el.type === 'text')
.map(el => el.content)
if (texts.length > 0) {
navigator.clipboard
.writeText(texts.join('\n'))
.then(() => toast.success(`已复制 ${texts.length} 条内容`))
}
}
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.code === 'Space' && !isInputActive()) {
setMode('select')
}
}
const handlePaste = (e: ClipboardEvent) => {
if (isInputActive()) return
if (!onPaste) return
onPaste(e)
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('paste', handlePaste)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('paste', handlePaste)
}
}, [
stepZoom,
mode,
setMode,
setViewport,
undo,
redo,
selectedIds,
focusedElementId,
readOnly,
store,
rootRef,
onDelete,
onPaste,
])
}

View File

@@ -0,0 +1,35 @@
import { useCallback } from 'react'
import { useDominoStore, useDominoStoreInstance } from '../components/canvas'
export function useZoomActions(rootRef: React.RefObject<HTMLElement | null>) {
const store = useDominoStoreInstance()
const scale = useDominoStore(state => state.viewport.scale)
const handleZoom = useCallback(
(targetScale: number) => {
const { viewport, zoomViewport } = store.getState()
const multiplier = targetScale / viewport.scale
const container = rootRef.current
const centerX = (container?.offsetWidth || window.innerWidth) / 2
const centerY = (container?.offsetHeight || window.innerHeight) / 2
zoomViewport(multiplier, centerX, centerY, 0.02, 4)
},
[store, rootRef],
)
const stepZoom = useCallback(
(delta: number) => {
const { viewport } = store.getState()
const step = Math.sign(delta) * 0.02
const targetScale = Math.max(0.02, Math.min(4, viewport.scale + step))
handleZoom(targetScale)
},
[store, handleZoom],
)
return {
handleZoom,
stepZoom,
scale,
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="14" height="14" viewBox="0 0 14 14"><defs><clipPath id="master_svg0_6180_230562"><rect x="0" y="0" width="14" height="14" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_230562)"><rect x="0" y="0" width="14" height="14" rx="0" fill="#626999" fill-opacity="0.6000000238418579" style="opacity:0;"/><path d="M12.8328918125,3.499918653125L1.1662244825,3.499918653125C0.8441228224999999,3.499801953125,0.5830078125,3.238687043125,0.5830078125,2.916585323125C0.5830078125,2.594483613125,0.8441228224999999,2.333368604395,1.1662244825,2.333368604395L12.8328918125,2.333251953125C13.1549928125,2.333368604395,13.4161078125,2.594483613125,13.4161078125,2.916585323125C13.4161078125,3.238687043125,13.1549928125,3.499801953125,12.8328918125,3.499918653125Z" fill-rule="evenodd" fill="#9E9E9E" fill-opacity="1"/><path d="M12.8328918125,11.6666667L1.1662244825,11.6666667C0.8441228224999999,11.66655,0.5830078125,11.40543509,0.5830078125,11.08333337C0.5830078125,10.76123166,0.8441228224999999,10.50011665127,1.1662244825,10.50011665127L12.8328918125,10.5C13.1549928125,10.50011665127,13.4161078125,10.76123166,13.4161078125,11.08333337C13.4161078125,11.40543509,13.1549928125,11.66655,12.8328918125,11.6666667Z" fill-rule="evenodd" fill="#9E9E9E" fill-opacity="1"/><path d="M23.9168761875,1.75016279375L12.2502088575,1.75016279375C11.9281071975,1.75004609375,11.6669921875,1.48893118375,11.6669921875,1.16682946375C11.6669921875,0.84472775375,11.9281071975,0.58361274502,12.2502088575,0.58361274502L23.9168761875,0.58349609375C24.238977187499998,0.58361274502,24.5000921875,0.84472775375,24.5000921875,1.16682946375C24.5000921875,1.48893118375,24.238977187499998,1.75004609375,23.9168761875,1.75016279375Z" fill-rule="evenodd" fill="#9E9E9E" fill-opacity="1" transform="matrix(0,1,-1,0,12.25048828125,-11.08349609375)"/><path d="M15.749884,1.75016279375L4.08321667,1.75016279375C3.76111501,1.75004609375,3.5,1.48893118375,3.5,1.16682946375C3.5,0.84472775375,3.76111501,0.58361274502,4.08321667,0.58361274502L15.749884,0.58349609375C16.071984999999998,0.58361274502,16.3331,0.84472775375,16.3331,1.16682946375C16.3331,1.48893118375,16.071984999999998,1.75004609375,15.749884,1.75016279375Z" fill-rule="evenodd" fill="#9E9E9E" fill-opacity="1" transform="matrix(0,1,-1,0,4.08349609375,-2.91650390625)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_210825"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_210825)"><rect x="0" y="0" width="16" height="16" rx="0" fill="#626999" fill-opacity="0.6000000238418579" style="opacity:0;"/><path d="M14.665881625,3.9999593765625L1.332548975,3.9999593765625C0.964432775,3.9998259765624997,0.666015625,3.7014088765624997,0.666015625,3.3332926665625C0.666015625,2.9651764365625,0.964432775,2.6667592922925,1.332548975,2.6667592922925L14.665881625,2.6666259765625C15.033998625,2.6667592922925,15.332415625,2.9651764365625,15.332415625,3.3332926665625C15.332415625,3.7014088765624997,15.033998625,3.9998259765624997,14.665881625,3.9999593765625Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M14.665881625,13.3333334L1.332548975,13.3333334C0.964432775,13.3332,0.666015625,13.0347829,0.666015625,12.66666669C0.666015625,12.29855046,0.964432775,12.00013331573,1.332548975,12.00013331573L14.665881625,12C15.033998625,12.00013331573,15.332415625,12.29855046,15.332415625,12.66666669C15.332415625,13.0347829,15.033998625,13.3332,14.665881625,13.3333334Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M27.333850375,2.0002035171875L14.000517725,2.0002035171875C13.632401525,2.0000701171874997,13.333984375,1.7016530171875,13.333984375,1.3335368071875C13.333984375,0.9654205771875,13.632401525,0.6670034329175,14.000517725,0.6670034329175L27.333850375,0.6668701171875C27.701967375000002,0.6670034329175,28.000384375,0.9654205771875,28.000384375,1.3335368071875C28.000384375,1.7016530171875,27.701967375000002,2.0000701171874997,27.333850375,2.0002035171875Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,-1,0,14.0008544921875,-12.6671142578125)"/><path d="M17.999866,2.0002035171875L4.66653335,2.0002035171875C4.29841715,2.0000701171874997,4,1.7016530171875,4,1.3335368071875C4,0.9654205771875,4.29841715,0.6670034329175,4.66653335,0.6670034329175L17.999866,0.6668701171875C18.367983000000002,0.6670034329175,18.6664,0.9654205771875,18.6664,1.3335368071875C18.6664,1.7016530171875,18.367983000000002,2.0000701171874997,17.999866,2.0002035171875Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,-1,0,4.6668701171875,-3.3331298828125)"/></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="18" height="18" viewBox="0 0 18 18"><defs><clipPath id="master_svg0_745_041104"><rect x="0" y="0" width="18" height="18" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_745_041104)"><g><path d="M6.485245809540749,1.934533643157959Q3.5584658095407486,2.976125643157959,2.2254258095407486,5.782185643157959C2.0476858095407486,6.156325643157959,2.2069058095407486,6.603715643157959,2.5810458095407487,6.781455643157959C2.9551858095407484,6.959195643157959,3.402575809540749,6.799975643157959,3.580315809540749,6.425835643157959Q4.646745809540748,4.180985643157959,6.988165809540749,3.3477056431579593Q9.329595809540749,2.514435643157959,11.574485809540748,3.580865643157959Q13.819285809540748,4.647295643157959,14.65258580954075,6.988725643157959Q14.795785809540748,7.391155643157959,14.880285809540748,7.801655643157959L14.709085809540749,7.655955643157959C14.393685809540749,7.387445643157959,13.920385809540749,7.425445643157959,13.65188580954075,7.740835643157959C13.383385809540748,8.056225643157958,13.421385809540748,8.529575643157958,13.736685809540749,8.798095643157959L15.221485809540749,10.06219564315796C15.536885809540749,10.330705643157959,16.010285809540747,10.292715643157958,16.27878580954075,9.97731564315796L17.54288580954075,8.492545643157959C17.81138580954075,8.17715564315796,17.77338580954075,7.703805643157959,17.457985809540748,7.435285643157959C17.142585809540748,7.166775643157959,16.66928580954075,7.204765643157959,16.40068580954075,7.520165643157959L16.36288580954075,7.564675643157959Q16.255685809540747,7.019445643157959,16.06578580954075,6.485795643157959Q15.024185809540748,3.559015643157959,12.21808580954075,2.225977643157959Q9.412025809540749,0.892944643157959,6.485245809540749,1.934533643157959ZM6.488905809540749,11.703375643157958C6.128905809540749,11.703375643157958,5.849905809540749,11.41542564315796,5.849905809540749,11.055425643157959C5.849905809540749,10.956425643157958,5.885905809540748,10.857425643157958,5.930905809540748,10.75842564315796L8.126905809540748,5.799425643157959C8.279905809540749,5.4574256431579595,8.558905809540748,5.250425643157959,8.936905809540749,5.250425643157959L9.017905809540748,5.250425643157959C9.395905809540748,5.250425643157959,9.665905809540748,5.4574256431579595,9.818905809540748,5.799425643157959L12.014885809540749,10.75842564315796C12.059885809540749,10.857425643157958,12.086885809540748,10.94742564315796,12.086885809540748,11.03742564315796C12.086885809540748,11.40642564315796,11.79888580954075,11.703375643157958,11.429885809540748,11.703375643157958C11.105885809540748,11.703375643157958,10.889885809540749,11.514375643157958,10.76388580954075,11.22642564315796L10.34088580954075,10.23642564315796L7.568905809540748,10.23642564315796L7.127905809540748,11.27142564315796C7.010905809540748,11.54137564315796,6.776905809540748,11.703375643157958,6.488905809540749,11.703375643157958ZM8.95490580954075,6.933425643157959L8.08190580954075,9.01242564315796L9.827905809540749,9.01242564315796L8.95490580954075,6.933425643157959ZM0.3541268095407486,10.976205643157959C0.6695178095407486,11.24472564315796,1.1428658095407487,11.20672564315796,1.4113858095407485,10.891335643157959L1.6685558095407487,10.589265643157958Q1.7706558095407485,11.05643564315796,1.9338758095407487,11.515075643157958Q2.9754558095407484,14.441875643157958,5.7815258095407485,15.774875643157959Q8.587585809540748,17.10787564315796,11.514385809540748,16.066275643157958Q14.441185809540748,15.024775643157959,15.774185809540748,12.218675643157958C15.951885809540748,11.84447564315796,15.792685809540748,11.39713564315796,15.41858580954075,11.21939564315796C15.04438580954075,11.04165564315796,14.597085809540749,11.200875643157959,14.41928580954075,11.57497564315796Q13.352885809540748,13.819875643157959,11.011485809540748,14.65317564315796Q8.670025809540748,15.48637564315796,6.425165809540749,14.41997564315796Q4.180315809540748,13.353575643157958,3.3470458095407487,11.012125643157958Q3.2419758095407487,10.71689564315796,3.1685358095407485,10.417335643157958L3.2974758095407486,10.52710564315796C3.6128658095407484,10.795625643157958,4.086215809540748,10.75762564315796,4.354735809540749,10.442235643157959C4.6232558095407486,10.126845643157958,4.585255809540748,9.653495643157958,4.2698658095407485,9.384975643157958L2.7850858095407487,8.120875643157959C2.5790358095407484,7.945445643157959,2.3055458095407486,7.900845643157959,2.065815809540749,7.978965643157959C1.8588658095407486,8.032865643157958,1.6762858095407487,8.17444564315796,1.5772258095407485,8.38263564315796L0.2692528095407486,9.91894564315796C0.0007359095407485916,10.234335643157959,0.038735309540748594,10.707685643157959,0.3541268095407486,10.976205643157959Z" fill-rule="evenodd" fill="currentColor" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" width="128" height="128">
<path
d="M5.94336,0.9999511967Q5.80412,0.9999511925,5.67548,1.0532355Q5.54684,1.10652,5.44838,1.204976L1.205157,5.4482Q1.156406,5.49696,1.118103,5.55428Q1.0797996,5.6116,1.0534159,5.6753Q1.0270322,5.739,1.0135819,5.80662Q1.000131606,5.87424,1.000131606,5.94318Q1.000131606,6.01212,1.0135819,6.07974Q1.0270322,6.14736,1.0534159,6.21106Q1.0797996,6.27475,1.118103,6.33208Q1.156406,6.3894,1.205157,6.43815L5.4478,10.68079Q5.54625,10.77925,5.67489,10.83253Q5.80353,10.88582,5.94277,10.88582Q6.08201,10.88582,6.21065,10.83253Q6.33929,10.77925,6.43775,10.68079Q6.5362,10.58234,6.58949,10.4537Q6.64277,10.32506,6.64277,10.18582Q6.64277,10.04658,6.58949,9.91794Q6.5362,9.7893,6.43775,9.69085L3.4469,6.7L10.7002,6.7Q10.76914,6.7,10.83676,6.68655Q10.90438,6.6731,10.96807,6.64672Q11.0318,6.62033,11.0891,6.58203Q11.1464,6.54372,11.1952,6.49497Q11.2439,6.44622,11.2822,6.3889Q11.3205,6.33157,11.3469,6.26788Q11.3733,6.20418,11.3867,6.13656Q11.4002,6.06894,11.4002,6Q11.4002,5.93106,11.3867,5.86344Q11.3733,5.79582,11.3469,5.73212Q11.3205,5.66843,11.2822,5.6111Q11.2439,5.55378,11.1952,5.50502Q11.1464,5.45627,11.0891,5.41797Q11.0318,5.37967,10.96807,5.35328Q10.90438,5.3269,10.83676,5.31345Q10.76914,5.3,10.7002,5.3L3.33326,5.3L6.43833,2.1949300000000003Q6.48708,2.14618,6.52539,2.08885Q6.56369,2.03153,6.59007,1.967829Q6.61646,1.9041329999999999,6.62991,1.836514Q6.64336,1.768895,6.64336,1.699951Q6.64336,1.6310069999999999,6.62991,1.563388Q6.61646,1.4957690000000001,6.59007,1.432073Q6.56369,1.368377,6.52539,1.311052Q6.48708,1.253727,6.43833,1.204976Q6.33988,1.10652,6.21124,1.0532355Q6.0826,0.9999512009,5.94336,0.9999511967Z"
fill-rule="evenodd"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><path d="M13.74883371875,2.25067863C14.08307271875,2.58491683,14.08307271875,3.126824,13.74883371875,3.4610621999999998L9.209896118749999,7.9993954L13.74883371875,12.538939C14.08307271875,12.873178,14.08307271875,13.415084,13.74883371875,13.749322C13.41459571875,14.083561,12.87268971875,14.083561,12.53845071875,13.749322L7.99951221875,9.209779300000001L3.4605739187499998,13.749322C3.12633571875,14.083561,2.58442854875,14.083561,2.25019034875,13.749322C1.91595216875,13.415084,1.91595216875,12.873178,2.25019034875,12.538939L6.78912881875,7.9993954L2.25019034875,3.4610621999999998C1.91595216875,3.126824,1.91595216875,2.58491683,2.25019034875,2.25067863C2.58442854875,1.91644045,3.12633571875,1.91644045,3.4605739187499998,2.25067863L7.99951221875,6.7890115L12.53845071875,2.25067863C12.87268971875,1.91644045,13.41459571875,1.91644045,13.74883371875,2.25067863Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><path d="M13.3332,8.6667074234375L2.66653335,8.6667074234375C2.29841715,8.6665740234375,2,8.3681569234375,2,8.0000407134375C2,7.6319244834375,2.29841715,7.3335073391675,2.66653335,7.3335073391675L13.3332,7.3333740234375C13.701316,7.3335073391675,13.999733,7.6319244834375,13.999733,8.0000407134375C13.999733,8.3681569234375,13.701316,8.6665740234375,13.3332,8.6667074234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M19.999215624999998,3.3336996109375L9.332548975,3.3336996109375C8.964432775,3.3335662109374997,8.666015625,3.0351491109374997,8.666015625,2.6670329009375C8.666015625,2.2989166709375,8.964432775,2.0004995266675,9.332548975,2.0004995266675L19.999215624999998,2.0003662109375C20.367331625,2.0004995266675,20.665748625,2.2989166709375,20.665748625,2.6670329009375C20.665748625,3.0351491109374997,20.367331625,3.3335662109374997,19.999215624999998,3.3336996109375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,-1,0,10.6663818359375,-6.6656494140625)"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><path d="M13.3332,8.6667074234375L2.66653335,8.6667074234375C2.29841715,8.6665740234375,2,8.3681569234375,2,8.0000407134375C2,7.6319244834375,2.29841715,7.3335073391675,2.66653335,7.3335073391675L13.3332,7.3333740234375C13.701316,7.3335073391675,13.999733,7.6319244834375,13.999733,8.0000407134375C13.999733,8.3681569234375,13.701316,8.6665740234375,13.3332,8.6667074234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></svg>

After

Width:  |  Height:  |  Size: 587 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_5625_136111"><rect x="0" y="0" width="24" height="24" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5625_136111)"><g transform="matrix(0,1,-1,0,10,-4)"><path d="M7,4C7,3.44771522,7.44771522,3,8,3L24,3C24.552284,3,25,3.44771522,25,4C25,4.5522846999999995,24.552284,5,24,5L8,5C7.44771522,5,7,4.5522846999999995,7,4Z" fill="#17171D" fill-opacity="1"/></g><g transform="matrix(0,-1,1,0,-7.999800205230713,18.000199794769287)"><path d="M6.000200294769288,13C5.448025944769287,13,5.0004002451892875,13.4476257,5.0004002451892875,13.99980003L5.000199794769287,20.9998002Q5.000200033187867,23.070868,6.464666094769287,24.535334Q7.929131994769287,25.9998,10.000200294769286,25.9998Q12.071268094769287,25.9998,13.535733694769288,24.535334Q15.000199794769287,23.070868,15.000199794769287,20.9998002L15.000199794769287,13.99980003C14.999999494769288,13.4476257,14.552375294769288,13,14.000199794769287,13C13.448026194769287,13,13.000400094769287,13.4476257,13.000400094769287,13.99980003L13.000199794769287,20.9998002Q13.000199794769287,22.242441200000002,12.121520994769288,23.121119999999998Q11.242841694769286,23.9998,10.000200294769286,23.9998Q8.757559094769288,23.9998,7.878879794769287,23.121119999999998Q7.000199794769287,22.2424402,7.000199794769287,20.9998002L7.000200294769288,13.99980003C7.000000194769287,13.4476257,6.552374594769287,13,6.000200294769288,13Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g><g transform="matrix(0,-1,1,0,-15.999800205230713,26.000199794769287)"><path d="M6.000200294769288,21C5.448025944769287,21,5.0004002451892875,21.4476257,5.0004002451892875,21.99980003L5.000199794769287,29.9997997Q5.000200033187867,32.070868000000004,6.464666094769287,33.535334Q7.929131994769287,34.9998,10.000200294769286,34.9998Q12.071268094769287,34.9998,13.535733694769288,33.535334Q15.000199794769287,32.070868000000004,15.000199794769287,29.9997997L15.000199794769287,21.99980003C14.999999494769288,21.4476257,14.552375294769288,21,14.000199794769287,21C13.448026194769287,21,13.000400094769287,21.4476257,13.000400094769287,21.99980003L13.000199794769287,29.9997997Q13.000199794769287,31.242441,12.121520994769288,32.12112Q11.242841694769286,32.9998,10.000200294769286,32.9998Q8.757559094769288,32.9998,7.878879794769287,32.12112Q7.000199794769287,31.242440000000002,7.000199794769287,29.9997997L7.000200294769288,21.99980003C7.000000194769287,21.4476257,6.552374594769287,21,6.000200294769288,21Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>

After

Width:  |  Height:  |  Size: 260 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle"><circle cx="12" cy="12" r="10"/></svg>

After

Width:  |  Height:  |  Size: 249 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="currentColor" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g><g></g><g><path d="M12.0069071875,3.0263671875Q11.6065171875,3.0263671875,11.3233871875,3.3094831875L7.9999771875,6.6324571875L4.6765671875,3.3094971875Q4.3934371875,3.0263671875,3.9930341875,3.0263671875Q3.5926271874999998,3.0263671875,3.3094971875,3.3094971875Q3.0263671875,3.5926271874999998,3.0263671875,3.9930341875Q3.0263671875,4.3934371875,3.3095111875,4.6765871875L6.632897187499999,7.9995271875L3.3094971875,11.3233771875Q3.0263671875,11.6065071875,3.0263671875,12.0069071875Q3.0263671875,12.4073171875,3.3094971875,12.6904471875Q3.5926271874999998,12.9735771875,3.9930341875,12.9735771875Q4.3934371875,12.9735771875,4.6765871875,12.6904371875L7.9999771875,9.3666071875L11.3233771875,12.6904471875Q11.6065071875,12.9735771875,12.0069071875,12.9735771875Q12.4073171875,12.9735771875,12.6904471875,12.6904471875Q12.9735771875,12.4073171875,12.9735771875,12.0069071875Q12.9735771875,11.6065071875,12.6904571875,11.3233871875L9.367047187499999,7.9995271875L12.6904471875,4.6765671875Q12.9735771875,4.3934371875,12.9735771875,3.9930341875Q12.9735771875,3.5926271874999998,12.6904471875,3.3094971875Q12.4073171875,3.0263671875,12.0069071875,3.0263671875Z" fill-rule="evenodd" fill="currentColor" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="14" height="14" viewBox="0 0 14 14"><defs><clipPath id="master_svg0_1338_83578"><rect x="0" y="0" width="14" height="14" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_1338_83578)"><g><g><path d="M7.4674159375,9.45509625Q7.2718559375,9.61725625,7.0178159375,9.61725625Q6.7637759375,9.61725625,6.5682159375,9.45509625L6.5644559375,9.45198625L4.5931959375,7.40835625L4.5919259375,7.40692625Q4.4294959375000005,7.22310625,4.4370659375,6.97790625Q4.4446359375,6.73271625,4.6180959375,6.55925625Q4.7915459375000005,6.38580625,5.0367359375,6.37823625Q5.2819259375,6.37066625,5.4657459375,6.53310625L5.4677759375,6.53488625L6.4293759375,7.49649625L6.4293759375,1.62369625L6.4295559375,1.62091125Q6.4453259375,1.37388225,6.6258459375,1.20451825Q6.8063659375,1.03515625,7.0539059375,1.03515625Q7.3014359375,1.03515625,7.4819559375,1.20451825Q7.6624859375,1.37388225,7.6782559375,1.62091125L7.6784359375,1.62369625L7.6784359375,7.49649625L8.6400359375,6.53488625L8.6420559375,6.53310625Q8.8258859375,6.37066625,9.0710759375,6.37823625Q9.3162659375,6.38579625,9.4897259375,6.55925625Q9.6631859375,6.73271625,9.6707559375,6.97790625Q9.6783159375,7.22310625,9.5158759375,7.40692625L9.5140959375,7.40895625L7.4705459375,9.45248625L7.4674159375,9.45509625ZM11.5290859375,9.28291625Q11.5350859375,9.02807625,11.7152859375,8.84782625Q11.8953859375,8.667546250000001,12.1501859375,8.66152625L12.1522859375,8.66147625L12.1543859375,8.66152625Q12.4091859375,8.667546250000001,12.5893859375,8.84782625Q12.7695859375,9.02807625,12.7755859375,9.28291625L12.7755859375,9.28394625L12.7755859375,9.28497625Q12.7755859375,10.65560625,11.9127859375,11.62225625Q11.0488559375,12.59035625,9.8272459375,12.59035625L4.1739259375,12.59035625Q2.9523359375,12.59035625,2.0884209375,11.62225625Q1.2255859375,10.65560625,1.2255859375,9.28497625L1.2255859375,9.28394625L1.2256103456,9.28291625Q1.2316325775,9.02807625,1.4117749375,8.84782625Q1.5919589375,8.667546250000001,1.8467849374999998,8.66152625L1.8488529375,8.66147625L1.8509199375,8.66152625Q2.1057479375,8.667546250000001,2.2859259375,8.84782625Q2.4660759375000003,9.02807625,2.4720959375,9.28291625L2.4721159375,9.28394625L2.4721159375,9.28497625Q2.4721159375,10.14005625,2.9708959375,10.74091625Q3.4658859375,11.33715625,4.1746859375,11.34335625L9.8272459375,11.34335625Q10.5342759375,11.34335625,11.0302259375,10.74424625Q11.5290859375,10.14162625,11.5290859375,9.28497625L11.5290859375,9.28394625L11.5290859375,9.28291625Z" fill-rule="evenodd" fill="currentColor" fill-opacity="1" style="mix-blend-mode:passthrough"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g transform="matrix(-1,0,0,1,32,0)"><g><rect x="16" y="0" width="16" height="16" rx="0" fill="#D8D8D8" fill-opacity="0"/></g><g><path d="M21.21485549,6.20300207C21.47528636,5.956938956,21.88281816,5.934569582,22.1699947,6.13589394L22.2522695,6.20300207L24.4994397,8.3264129L26.7477303,6.20300207C27.0081615,5.956938956,27.4156933,5.934569582,27.7028699,6.13589394L27.7851443,6.20300207C28.0455756,6.44906518,28.0692511,6.83411378,27.8561711,7.1054469000000005L27.7851443,7.1831827L25.0187068,9.796998C24.7582762,10.0430613,24.3507442,10.0654306,24.0635679,9.8641059L23.981293,9.796998L21.21485549,7.1831827C20.928381503,6.91251332,20.928381503,6.4736715,21.21485549,6.20300207Z" fill="#8D8D99" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 879 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="12" height="12" viewBox="0 0 12 12"><defs><clipPath id="master_svg0_2041_012470"><rect x="0" y="0" width="12" height="12" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_2041_012470)"><g><path d="M5.99832875,0.857177734375C3.16252875,0.857177734375,0.85546875,3.164237734375,0.85546875,6.000037734375C0.85546875,8.837117734375,3.16252875,11.144177734375,5.99832875,11.144177734375C8.83411875,11.144177734375,11.14116875,8.837117734375,11.14116875,6.001327734375C11.14116875,3.165527734375,8.83411875,0.857177734375,5.99832875,0.857177734375Z" fill="#FF5219" fill-opacity="1"/></g><g><path d="M6.854915,8.571533203125C6.854915,9.044923203125,6.4711549999999995,9.428673203125001,5.997768,9.428673203125001C5.524381,9.428673203125001,5.140625,9.044923203125,5.140625,8.571533203125C5.140625,8.098143203125,5.524381,7.714393203125,5.997768,7.714393203125C6.4711549999999995,7.714393203125,6.854915,8.098143203125,6.854915,8.571533203125ZM5.354911,6.214393203125001L5.354911,3.2143902031250002C5.354911,2.859350203125,5.642728,2.571533203125,5.997768,2.571533203125C6.352805,2.571533203125,6.640625,2.859350203125,6.640625,3.2143902031250002L6.640625,6.214393203125001C6.640625,6.569433203125,6.352805,6.857243203125,5.997768,6.857243203125C5.642728,6.857243203125,5.354911,6.569433203125,5.354911,6.214393203125001Z" fill="#FFFFFF" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_233152"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_233152)"><rect x="0" y="0" width="16" height="16" rx="0" fill="#626999" fill-opacity="0.6000000238418579" style="opacity:0;"/><path d="M2,3.33349609375L2,5.33349609375Q2.000000079472862,6.16192339375,2.5857864,6.74770929375Q3.171573,7.33349609375,4,7.33349609375L12,7.33349609375Q12.828428,7.33349609375,13.414214,6.74770929375Q14,6.16192439375,14,5.33349609375L14,3.33349609375Q14,2.5050677937500003,13.414214,1.9192824337499998Q12.828428,1.33349609375,12,1.33349609375L4,1.33349609375Q3.1715728,1.33349609375,2.58578646,1.9192824337499998Q2,2.50506879375,2,3.33349609375ZM3.5285954,5.80490019375Q3.3333334,5.60963819375,3.3333334,5.33349609375L3.3333334,3.33349609375Q3.3333334,3.05735369375,3.5285954,2.86209149375Q3.7238576,2.66682949375,4,2.66682949375L12,2.66682949375Q12.276142,2.66682949375,12.471405,2.86209179375Q12.666667,3.05735419375,12.666667,3.33349609375L12.666667,5.33349609375Q12.666667,5.60963769375,12.471405,5.80490019375Q12.276142,6.00016259375,12,6.00016259375L4,6.00016259375Q3.7238578,6.00016259375,3.5285954,5.80490019375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M2,10.66650390625L2,12.66650390625Q2.000000079472862,13.494931206250001,2.5857864,14.080717106249999Q3.171573,14.66650390625,4,14.66650390625L12,14.66650390625Q12.828428,14.66650390625,13.414214,14.080717106249999Q14,13.494932206249999,14,12.66650390625L14,10.66650390625Q14,9.83807560625,13.414214,9.25229024625Q12.828428,8.66650390625,12,8.66650390625L4,8.66650390625Q3.1715728,8.66650390625,2.58578646,9.25229024625Q2,9.83807660625,2,10.66650390625ZM3.5285954,13.13790800625Q3.3333334,12.942646006250001,3.3333334,12.66650390625L3.3333334,10.66650390625Q3.3333334,10.39036150625,3.5285954,10.19509930625Q3.7238576,9.99983730625,4,9.99983730625L12,9.99983730625Q12.276142,9.99983730625,12.471405,10.19509960625Q12.666667,10.39036200625,12.666667,10.66650390625L12.666667,12.66650390625Q12.666667,12.942645506249999,12.471405,13.13790800625Q12.276142,13.33317040625,12,13.33317040625L4,13.33317040625Q3.7238578,13.33317040625,3.5285954,13.13790800625Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><g transform="matrix(-1,0,0,1,32,0)"><path d="M20,6.6666667L20,12.000001Q20,13.1045704,20.78104872,13.8856182Q21.5620975,14.666667,22.6666667,14.666667L28.000000999999997,14.666667Q29.104569400000003,14.666667,29.8856182,13.8856182Q30.666667,13.1045694,30.666667,12.000001L30.666667,6.6666667Q30.666667,5.562098,29.8856182,4.78104866Q29.104569400000003,4,28.000000999999997,4L22.6666667,4Q21.5620973,4,20.78104854,4.78104866Q20,5.5620973,20,6.6666667ZM21.7238576,12.9428101Q21.3333334,12.5522861,21.3333334,12.000001L21.3333334,6.6666667Q21.3333334,6.114382,21.7238576,5.7238578Q22.114382,5.3333334,22.6666667,5.3333334L28.000000999999997,5.3333334Q28.5522852,5.3333334,28.942809099999998,5.7238576000000005Q29.333334,6.1143818,29.333334,6.6666667L29.333334,12.000001Q29.333334,12.5522861,28.942809099999998,12.9428101Q28.552284200000003,13.333334,28.000000999999997,13.333334L22.6666667,13.333334Q22.1143825,13.333334,21.7238576,12.9428101Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M28.159809578125,6.39065444375C28.159809578125,6.02253824375,27.861392478125,5.72412109375,27.493276078125,5.72412109375C27.125159778125,5.72412109375,26.826742678125,6.02253824375,26.826742678125,6.39065444375L26.826609378125,9.23847459375L25.447050928125,9.23847719375C25.078934728125,9.23861029375,24.780517578125,9.53702739375,24.780517578125,9.90514369375C24.780517578125,10.27326009375,25.078934728125,10.57167719375,25.447050928125,10.57167719375L27.493277278125,10.57180689375C27.861466378125,10.57180599375,28.159942578125,10.27332969375,28.159942578125,9.90513989375L28.159809578125,6.39065444375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(-0.7071067690849304,0.7071067690849304,0.7071067690849304,0.7071067690849304,38.25542452659283,-15.845915399622754)"/><path d="M26.666585953125,4.33349609375L26.666585953125,4.00016309375Q26.666585953125,3.44787769375,26.276061053124998,3.05735389375Q25.885536153125003,2.66682949375,25.333250953125,2.66682949375L19.999918653125,2.66682949375Q19.447634253125,2.66682949375,19.057109753125,3.05735389375Q18.666585453125,3.44787809375,18.666585453125,4.00016309375L18.666585353125,9.33349709375Q18.666585353125,9.88578219375,19.057109553125,10.27630619375Q19.447634453125,10.66683009375,19.999918953125,10.66683009375L20.333251953125,10.66683009375L20.333251953125,12.00016309375L19.999918953125,12.00016309375Q18.895349453125,12.00016309375,18.114300673125,11.21911429375Q17.333251953125,10.43806649375,17.333251953125,9.33349709375L17.33325207233429,4.00016309375Q17.33325207233429,2.89559339375,18.114300673125,2.1145447537499997Q18.895349353125,1.33349609375,19.999918653125,1.33349609375L25.333250953125,1.33349609375Q26.437821353125003,1.33349609375,27.218870153125,2.11454457375Q27.999918953125,2.89559349375,27.999918953125,4.00016309375L27.999918953125,4.33349609375L26.666585953125,4.33349609375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_232954"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_232954)"><rect x="1.333251953125" y="2.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/><rect x="4.666748046875" y="7.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/><rect x="3.333251953125" y="12" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></svg>

After

Width:  |  Height:  |  Size: 777 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_232948"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_232948)"><rect x="1.333251953125" y="2.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/><rect x="1.333251953125" y="7.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/><rect x="1.333251953125" y="12" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></svg>

After

Width:  |  Height:  |  Size: 777 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_232960"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_232960)"><rect x="1.333251953125" y="2.66668701171875" width="13.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/><rect x="8" y="7.3333740234375" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/><rect x="5.333251953125" y="12" width="9.333333969116211" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1"/></g></svg>

After

Width:  |  Height:  |  Size: 764 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_233570"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_233570)"><path d="M14,10.0000407134375C14,9.6318508434375,14.29847682,9.3333740234375,14.66666669,9.3333740234375L18,9.3333740234375C18.3681898,9.3333740234375,18.666667,9.6318508434375,18.666667,10.0000407134375C18.666667,10.3682306234375,18.3681898,10.6667074234375,18,10.6667074234375L14.66666669,10.6667074234375C14.29847682,10.6667074234375,14,10.3682306234375,14,10.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,28,18.666748046875)"/><path d="M6.6669921875,10.0000407134375C6.6669921875,9.6318508434375,6.9654690075,9.3333740234375,7.3336588775,9.3333740234375L10.6669921875,9.3333740234375C11.0351819875,9.3333740234375,11.3336591875,9.6318508434375,11.3336591875,10.0000407134375C11.3336591875,10.3682306234375,11.0351819875,10.6667074234375,10.6669921875,10.6667074234375L7.3336588775,10.6667074234375C6.9654690075,10.6667074234375,6.6669921875,10.3682306234375,6.6669921875,10.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,13.333984375,18.666748046875)"/><path d="M2,4.6667075234375L2,3.3333740234375C2,2.2288044734375,2.89543045,1.3333740234375,4,1.3333740234375L12,1.3333740234375C13.10457,1.3333740234375,14,2.2288044734375,14,3.3333740234375L14,4.6667075234375C14,5.7712774234375,13.10457,6.6667075234375,12,6.6667075234375L4,6.6667075234375C2.89543045,6.6667075234375,2,5.7712774234375,2,4.6667075234375ZM3.3333334,4.6667075234375Q3.3333334,4.9428496234375,3.5285954,5.1381118234375Q3.7238578,5.3333740234375,4,5.3333740234375L12,5.3333740234375Q12.276142,5.3333740234375,12.471404,5.1381118234375Q12.666667,4.9428489234375,12.666667,4.6667075234375L12.666667,3.3333740234375Q12.666667,3.0572324234375,12.471404,2.8619694234375Q12.276142,2.6667074234375,12,2.6667074234375L4,2.6667074234375Q3.7238576,2.6667074234375,3.5285954,2.8619694234375Q3.3333334,3.0572316234375,3.3333334,3.3333740234375L3.3333334,4.6667075234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M14.6662078125,7.3334554703125L7.9995411625,7.3334554703125C7.6314249625,7.3333220703125,7.3330078125,7.0349049703125,7.3330078125,6.6667887603125C7.3330078125,6.2986725303125,7.6314249625,6.0002553860425,7.9995411625,6.0002553860425L14.6662078125,6.0001220703125C15.0343242125,6.0002553860425,15.332740812499999,6.2986725303125,15.332740812499999,6.6667887603125C15.332740812499999,7.0349049703125,15.0343242125,7.3333220703125,14.6662078125,7.3334554703125Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,1,0,1.3328857421875,-1.3328857421875)"/><path d="M11.0235486,9.7239990234375C10.6554322,9.7239990234375,10.3570151,10.0224161734375,10.3570151,10.3905323734375L10.3568816,12.0808806234375L8.66653335,12.0808806234375C8.29841715,12.0810141234375,8,12.3794312234375,8,12.7475476234375C8,13.1156640234375,8.29841715,13.4140811234375,8.66653335,13.4140811234375L11.0235481,13.4142141234375C11.3917384,13.4142141234375,11.6902151,13.1157374234375,11.6902151,12.7475471234375L11.6902151,10.3905323734375C11.6900821,10.0224161734375,11.391665,9.7239990234375,11.0235486,9.7239990234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(-0.7071067690849304,0.7071067690849304,0.7071067690849304,0.7071067690849304,6.780948620631534,-2.8087606612898526)"/></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_233588"><rect x="0" y="16" width="16" height="16" rx="0"/></clipPath></defs><g transform="matrix(1,0,0,-1,0,32)" clip-path="url(#master_svg0_6180_233588)"><path d="M14,26.0000407134375C14,25.6318508434375,14.29847682,25.3333740234375,14.66666669,25.3333740234375L18,25.3333740234375C18.3681898,25.3333740234375,18.666667,25.6318508434375,18.666667,26.0000407134375C18.666667,26.3682306234375,18.3681898,26.6667074234375,18,26.6667074234375L14.66666669,26.6667074234375C14.29847682,26.6667074234375,14,26.3682306234375,14,26.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,28,50.666748046875)"/><path d="M6.6669921875,26.0000407134375C6.6669921875,25.6318508434375,6.9654690075,25.3333740234375,7.3336588775,25.3333740234375L10.6669921875,25.3333740234375C11.0351819875,25.3333740234375,11.3336591875,25.6318508434375,11.3336591875,26.0000407134375C11.3336591875,26.3682306234375,11.0351819875,26.6667074234375,10.6669921875,26.6667074234375L7.3336588775,26.6667074234375C6.9654690075,26.6667074234375,6.6669921875,26.3682306234375,6.6669921875,26.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,13.333984375,50.666748046875)"/><path d="M2,20.666707523437502L2,19.3333740234375C2,18.2288044734375,2.89543045,17.3333740234375,4,17.3333740234375L12,17.3333740234375C13.10457,17.3333740234375,14,18.2288044734375,14,19.3333740234375L14,20.666707523437502C14,21.7712774234375,13.10457,22.666707523437502,12,22.666707523437502L4,22.666707523437502C2.89543045,22.666707523437502,2,21.7712774234375,2,20.666707523437502ZM3.3333334,20.666707523437502Q3.3333334,20.9428496234375,3.5285954,21.138111823437498Q3.7238578,21.3333740234375,4,21.3333740234375L12,21.3333740234375Q12.276142,21.3333740234375,12.471404,21.138111823437498Q12.666667,20.9428489234375,12.666667,20.666707523437502L12.666667,19.3333740234375Q12.666667,19.0572324234375,12.471404,18.8619694234375Q12.276142,18.6667074234375,12,18.6667074234375L4,18.6667074234375Q3.7238576,18.6667074234375,3.5285954,18.8619694234375Q3.3333334,19.0572316234375,3.3333334,19.3333740234375L3.3333334,20.666707523437502Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M14.6662078125,23.3334554703125L7.9995411625,23.3334554703125C7.6314249625,23.3333220703125,7.3330078125,23.0349049703125,7.3330078125,22.6667887603125C7.3330078125,22.2986725303125,7.6314249625,22.0002553860425,7.9995411625,22.0002553860425L14.6662078125,22.0001220703125C15.0343242125,22.0002553860425,15.332740812499999,22.2986725303125,15.332740812499999,22.6667887603125C15.332740812499999,23.0349049703125,15.0343242125,23.3333220703125,14.6662078125,23.3334554703125Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,1,0,-14.6671142578125,14.6671142578125)"/><path d="M11.0235486,25.7239990234375C10.6554322,25.7239990234375,10.3570151,26.0224161734375,10.3570151,26.3905323734375L10.3568816,28.0808806234375L8.66653335,28.0808806234375C8.29841715,28.081014123437498,8,28.3794312234375,8,28.747547623437498C8,29.1156640234375,8.29841715,29.4140811234375,8.66653335,29.4140811234375L11.0235481,29.4142141234375C11.3917384,29.4142141234375,11.6902151,29.1157374234375,11.6902151,28.7475471234375L11.6902151,26.3905323734375C11.6900821,26.0224161734375,11.391665,25.7239990234375,11.0235486,25.7239990234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(-0.7071067690849304,0.7071067690849304,0.7071067690849304,0.7071067690849304,-4.532759684727353,1.8775310333512607)"/></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.8 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><path d="M12.000651375,5.3334036171875L12.000651375,3.9999962171875L10.667451875,4.0000408171875C10.299334575,3.9999075171875,10.000917475,3.7014904171875,10.000917475,3.3333740171875C10.000917475,2.9652579171875,10.299334575,2.6668408171875,10.667451875,2.6667074171875003L12.000651375,2.6667074171875003L12.000651375,1.3334034671875C12.000784375,0.9652872671875,12.299202375,0.6668701171875,12.667318375,0.6668701171875C13.035433375,0.6668701171875,13.333850375,0.9652872671875,13.333850375,1.3334034671875L13.333895375,2.6667074171875003L14.667451375,2.6667074171875003C15.035567375,2.6668408171875,15.333984375,2.9652579171875,15.333984375,3.3333740171875C15.333984375,3.7014904171875,15.035567375,3.9999075171875,14.667451375,3.9999075171875L13.333940375,3.9999521171875L13.333984375,5.3334036171875C13.333850375,5.7015199171875,13.035433375,5.9999370171875,12.667318375,5.9999370171875C12.299202375,5.9999370171875,12.000784375,5.7015199171875,12.000651375,5.3334036171875ZM14.667318375,8.000041017187499L14.667318375,12.0000411171875Q14.667318375,13.1046091171875,13.886268375,13.8856581171875Q13.105219375,14.6667081171875,12.000651375,14.6667081171875L4.000651875,14.6667081171875Q2.896081875,14.6667081171875,2.1150333249999997,13.8856601171875Q1.333984375,13.1046121171875,1.333984375,12.0000411171875L1.333984375,4.0000415171874995Q1.333984375,2.8954718171875,2.1150333249999997,2.1144228171875Q2.896081875,1.3333741471875,4.000651875,1.3333740271874999L8.000651375,1.3333742571875L8.000651375,2.6667077171875L4.000651875,2.6667074171875003Q2.667318075,2.6667077171875,2.667318075,4.0000415171874995L2.667317875,7.5833759171875L3.587150075,6.8934092171875Q4.226006275,6.4142675171875,5.019183675,6.5069141171875Q5.812361275,6.5995617171875,6.323593175,7.2130403171875L8.576734075000001,9.9168100171875L8.975601175000001,9.5883159171875Q9.571888875,9.0972557171875,10.343452475,9.1345024171875Q11.115016975,9.1717491171875,11.661230375,9.7179632171875L13.333984375,11.3907191171875L13.333984375,8.000041017187499L14.667318375,8.000041017187499ZM13.028560375,12.9709121171875L10.718420975,10.6607723171875Q10.536350275,10.4787006171875,10.279161475,10.4662857171875Q10.021973575,10.4538698171875,9.823211675,10.6175566171875L6.525432575,13.3333741171875L12.000651375,13.3333741171875Q12.695838375,13.3333741171875,13.028560375,12.9709121171875ZM4.428231975,13.3333741171875L7.547494875,10.7644561171875L5.299298074999999,8.0666194171875Q5.128887675,7.8621268171875,4.864495075,7.8312445171875Q4.600102175,7.8003616171875,4.387150275,7.9600754171875L2.733851175,9.2000503171875C2.712481275,9.2160473171875,2.690259175,9.2306852171875,2.667317875,9.2439194171875L2.667317775,12.0000411171875Q2.667318075,13.3333741171875,4.000651875,13.3333741171875L4.428231975,13.3333741171875Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_5625_136616"><rect x="0" y="0" width="24" height="24" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_5625_136616)"><g><path d="M5,19C5,18.44771522,5.44771522,18,6,18L12,18C12.552284700000001,18,13,18.44771522,13,19C13,19.5522847,12.552284700000001,20,12,20L6,20C5.44771522,20,5,19.5522847,5,19Z" fill="#17171D" fill-opacity="1"/></g><g transform="matrix(0,-1,1,0,-10.99951171875,27.00048828125)"><path d="M8.00048828125,21L22.00048828125,27L22.00048828125,25L8.00048828125,19L8.00048828125,21Z" fill="#17171D" fill-opacity="1"/></g><g><path d="M11,5C11,4.44771522,11.44771522,4,12,4L18,4C18.5522847,4,19,4.44771522,19,5C19,5.5522846999999995,18.5522847,6,18,6L12,6C11.44771522,6,11,5.5522846999999995,11,5Z" fill="#17171D" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 949 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_233445"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_233445)"><path d="M14,10.0000407134375C14,9.6318508434375,14.29847682,9.3333740234375,14.66666669,9.3333740234375L18,9.3333740234375C18.3681898,9.3333740234375,18.666667,9.6318508434375,18.666667,10.0000407134375C18.666667,10.3682306234375,18.3681898,10.6667074234375,18,10.6667074234375L14.66666669,10.6667074234375C14.29847682,10.6667074234375,14,10.3682306234375,14,10.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,28,18.666748046875)"/><path d="M6.6669921875,10.0000407134375C6.6669921875,9.6318508434375,6.9654690075,9.3333740234375,7.3336588775,9.3333740234375L10.6669921875,9.3333740234375C11.0351819875,9.3333740234375,11.3336591875,9.6318508434375,11.3336591875,10.0000407134375C11.3336591875,10.3682306234375,11.0351819875,10.6667074234375,10.6669921875,10.6667074234375L7.3336588775,10.6667074234375C6.9654690075,10.6667074234375,6.6669921875,10.3682306234375,6.6669921875,10.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,13.333984375,18.666748046875)"/><path d="M2,4.6667075234375L2,3.3333740234375C2,2.2288044734375,2.89543045,1.3333740234375,4,1.3333740234375L12,1.3333740234375C13.10457,1.3333740234375,14,2.2288044734375,14,3.3333740234375L14,4.6667075234375C14,5.7712774234375,13.10457,6.6667075234375,12,6.6667075234375L4,6.6667075234375C2.89543045,6.6667075234375,2,5.7712774234375,2,4.6667075234375ZM3.3333334,4.6667075234375Q3.3333334,4.9428496234375,3.5285954,5.1381118234375Q3.7238578,5.3333740234375,4,5.3333740234375L12,5.3333740234375Q12.276142,5.3333740234375,12.471404,5.1381118234375Q12.666667,4.9428489234375,12.666667,4.6667075234375L12.666667,3.3333740234375Q12.666667,3.0572324234375,12.471404,2.8619694234375Q12.276142,2.6667074234375,12,2.6667074234375L4,2.6667074234375Q3.7238576,2.6667074234375,3.5285954,2.8619694234375Q3.3333334,3.0572316234375,3.3333334,3.3333740234375L3.3333334,4.6667075234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M14.6662078125,7.3334554703125L7.9995411625,7.3334554703125C7.6314249625,7.3333220703125,7.3330078125,7.0349049703125,7.3330078125,6.6667887603125C7.3330078125,6.2986725303125,7.6314249625,6.0002553860425,7.9995411625,6.0002553860425L14.6662078125,6.0001220703125C15.0343242125,6.0002553860425,15.332740812499999,6.2986725303125,15.332740812499999,6.6667887603125C15.332740812499999,7.0349049703125,15.0343242125,7.3333220703125,14.6662078125,7.3334554703125Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,1,0,1.3328857421875,-1.3328857421875)"/><path d="M11.0235486,9.7239990234375C10.6554322,9.7239990234375,10.3570151,10.0224161734375,10.3570151,10.3905323734375L10.3568816,12.0808806234375L8.66653335,12.0808806234375C8.29841715,12.0810141234375,8,12.3794312234375,8,12.7475476234375C8,13.1156640234375,8.29841715,13.4140811234375,8.66653335,13.4140811234375L11.0235481,13.4142141234375C11.3917384,13.4142141234375,11.6902151,13.1157374234375,11.6902151,12.7475471234375L11.6902151,10.3905323734375C11.6900821,10.0224161734375,11.391665,9.7239990234375,11.0235486,9.7239990234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(-0.7071067690849304,0.7071067690849304,0.7071067690849304,0.7071067690849304,6.780948620631534,-2.8087606612898526)"/></g></svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_233506"><rect x="0" y="16" width="16" height="16" rx="0"/></clipPath></defs><g transform="matrix(1,0,0,-1,0,32)" clip-path="url(#master_svg0_6180_233506)"><path d="M14,26.0000407134375C14,25.6318508434375,14.29847682,25.3333740234375,14.66666669,25.3333740234375L18,25.3333740234375C18.3681898,25.3333740234375,18.666667,25.6318508434375,18.666667,26.0000407134375C18.666667,26.3682306234375,18.3681898,26.6667074234375,18,26.6667074234375L14.66666669,26.6667074234375C14.29847682,26.6667074234375,14,26.3682306234375,14,26.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,28,50.666748046875)"/><path d="M6.6669921875,26.0000407134375C6.6669921875,25.6318508434375,6.9654690075,25.3333740234375,7.3336588775,25.3333740234375L10.6669921875,25.3333740234375C11.0351819875,25.3333740234375,11.3336591875,25.6318508434375,11.3336591875,26.0000407134375C11.3336591875,26.3682306234375,11.0351819875,26.6667074234375,10.6669921875,26.6667074234375L7.3336588775,26.6667074234375C6.9654690075,26.6667074234375,6.6669921875,26.3682306234375,6.6669921875,26.0000407134375Z" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,13.333984375,50.666748046875)"/><path d="M2,20.666707523437502L2,19.3333740234375C2,18.2288044734375,2.89543045,17.3333740234375,4,17.3333740234375L12,17.3333740234375C13.10457,17.3333740234375,14,18.2288044734375,14,19.3333740234375L14,20.666707523437502C14,21.7712774234375,13.10457,22.666707523437502,12,22.666707523437502L4,22.666707523437502C2.89543045,22.666707523437502,2,21.7712774234375,2,20.666707523437502ZM3.3333334,20.666707523437502Q3.3333334,20.9428496234375,3.5285954,21.138111823437498Q3.7238578,21.3333740234375,4,21.3333740234375L12,21.3333740234375Q12.276142,21.3333740234375,12.471404,21.138111823437498Q12.666667,20.9428489234375,12.666667,20.666707523437502L12.666667,19.3333740234375Q12.666667,19.0572324234375,12.471404,18.8619694234375Q12.276142,18.6667074234375,12,18.6667074234375L4,18.6667074234375Q3.7238576,18.6667074234375,3.5285954,18.8619694234375Q3.3333334,19.0572316234375,3.3333334,19.3333740234375L3.3333334,20.666707523437502Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/><path d="M14.6662078125,23.3334554703125L7.9995411625,23.3334554703125C7.6314249625,23.3333220703125,7.3330078125,23.0349049703125,7.3330078125,22.6667887603125C7.3330078125,22.2986725303125,7.6314249625,22.0002553860425,7.9995411625,22.0002553860425L14.6662078125,22.0001220703125C15.0343242125,22.0002553860425,15.332740812499999,22.2986725303125,15.332740812499999,22.6667887603125C15.332740812499999,23.0349049703125,15.0343242125,23.3333220703125,14.6662078125,23.3334554703125Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0,1,1,0,-14.6671142578125,14.6671142578125)"/><path d="M11.0235486,25.7239990234375C10.6554322,25.7239990234375,10.3570151,26.0224161734375,10.3570151,26.3905323734375L10.3568816,28.0808806234375L8.66653335,28.0808806234375C8.29841715,28.081014123437498,8,28.3794312234375,8,28.747547623437498C8,29.1156640234375,8.29841715,29.4140811234375,8.66653335,29.4140811234375L11.0235481,29.4142141234375C11.3917384,29.4142141234375,11.6902151,29.1157374234375,11.6902151,28.7475471234375L11.6902151,26.3905323734375C11.6900821,26.0224161734375,11.391665,25.7239990234375,11.0235486,25.7239990234375Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(-0.7071067690849304,0.7071067690849304,0.7071067690849304,0.7071067690849304,-4.532759684727353,1.8775310333512607)"/></g></svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6286_205963"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6286_205963)"><g><path d="M2,4.666585453125L2,3.333251953125C2,2.228682403125,2.89543045,1.333251953125,4,1.333251953125L12,1.333251953125C13.10457,1.333251953125,14,2.228682403125,14,3.333251953125L14,4.666585453125C14,5.771155353125,13.10457,6.666585453125,12,6.666585453125L8.6663413,6.666585453125L8.6663413,11.057662053125L9.1952567,10.528746653125C9.455648400000001,10.268543253125,9.8776741,10.268543253125,10.1379719,10.528841053125C10.3982697,10.789138753125,10.3982697,11.211164453125,10.1379719,11.471460953125L8.471405,13.138216953125C8.3435845,13.266036953125,8.1767745,13.331101953125,8.0092587,13.333414953125L13.333333,13.333414953125C13.701523,13.333414953125,14,13.631890953125,14,14.000080953125C14,14.368270953125,13.701523,14.666747953125,13.333333,14.666747953125L2.66666603,14.666747953125C2.29847717,14.666747953125,2,14.368270953125,2,14.000080953125C2,13.631890953125,2.29847717,13.333414953125,2.66666603,13.333414953125L7.9907413,13.333414953125C7.8232255,13.331101953125,7.6564155,13.266036953125,7.528595,13.138216953125L5.8619342,11.471555953125C5.6017308,11.211164453125,5.6017308,10.789138753125,5.8620284,10.528841053125C6.1223259,10.268543253125,6.5443516,10.268543253125,6.8046489,10.528841053125L7.3330159,11.057124153125L7.3331413,6.666777653125L7.3331413,6.666585453125L4,6.666585453125C2.89543045,6.666585453125,2,5.771155353125,2,4.666585453125ZM3.3333334,4.666585453125Q3.3333334,4.942727553125,3.5285954,5.137989753125Q3.7238578,5.333251953125,4,5.333251953125L12,5.333251953125Q12.276142,5.333251953125,12.471404,5.137989753125Q12.666667,4.942726853125,12.666667,4.666585453125L12.666667,3.333251953125Q12.666667,3.057110353125,12.471404,2.861847353125Q12.276142,2.666585353125,12,2.666585353125L4,2.666585353125Q3.7238576,2.666585353125,3.5285954,2.861847353125Q3.3333334,3.057109553125,3.3333334,3.333251953125L3.3333334,4.666585453125ZM6.6669922,8.666829553125C6.6669922,9.035019353125,6.3685155,9.333496053125,6.0003257,9.333496053125L2.66699219,9.333496053125C2.29880238,9.333496053125,2.00032520294,9.035019353125,2.00032520294,8.666829553125C2.00032520294,8.298639253125,2.29880238,8.000162553125,2.66699219,8.000162553125L6.0003257,8.000162553125C6.3685155,8.000162553125,6.6669922,8.298639253125,6.6669922,8.666829553125ZM14,8.666829553125C14,9.035019353125,13.701523,9.333496053125,13.333333,9.333496053125L10,9.333496053125C9.6318102,9.333496053125,9.333333,9.035019353125,9.333333,8.666829553125C9.333333,8.298639253125,9.6318102,8.000162553125,10,8.000162553125L13.333333,8.000162553125C13.701523,8.000162553125,14,8.298639253125,14,8.666829553125Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6286_205946"><rect x="0" y="16" width="16" height="16" rx="0"/></clipPath></defs><g transform="matrix(1,0,0,-1,0,32)" clip-path="url(#master_svg0_6286_205946)"><g><path d="M2,20.666585453125002L2,19.333251953125C2,18.228682403125,2.89543045,17.333251953125,4,17.333251953125L12,17.333251953125C13.10457,17.333251953125,14,18.228682403125,14,19.333251953125L14,20.666585453125002C14,21.771155353125,13.10457,22.666585453125002,12,22.666585453125002L8.6663413,22.666585453125002L8.6663413,27.057662053125L9.1952567,26.528746653124998C9.455648400000001,26.268543253125,9.8776741,26.268543253125,10.1379719,26.528841053125C10.3982697,26.789138753125002,10.3982697,27.211164453125,10.1379719,27.471460953125L8.471405,29.138216953125C8.3435845,29.266036953125003,8.1767745,29.331101953125,8.0092587,29.333414953125L13.333333,29.333414953125C13.701523,29.333414953125,14,29.631890953125,14,30.000080953125C14,30.368270953125,13.701523,30.666747953125,13.333333,30.666747953125L2.66666603,30.666747953125C2.29847717,30.666747953125,2,30.368270953125,2,30.000080953125C2,29.631890953125,2.29847717,29.333414953125,2.66666603,29.333414953125L7.9907413,29.333414953125C7.8232255,29.331101953125,7.6564155,29.266036953125003,7.528595,29.138216953125L5.8619342,27.471555953124998C5.6017308,27.211164453125,5.6017308,26.789138753125002,5.8620284,26.528841053125C6.1223259,26.268543253125,6.5443516,26.268543253125,6.8046489,26.528841053125L7.3330159,27.057124153125002L7.3331413,22.666777653125L7.3331413,22.666585453125002L4,22.666585453125002C2.89543045,22.666585453125002,2,21.771155353125,2,20.666585453125002ZM3.3333334,20.666585453125002Q3.3333334,20.942727553125,3.5285954,21.137989753124998Q3.7238578,21.333251953125,4,21.333251953125L12,21.333251953125Q12.276142,21.333251953125,12.471404,21.137989753124998Q12.666667,20.942726853125,12.666667,20.666585453125002L12.666667,19.333251953125Q12.666667,19.057110353125,12.471404,18.861847353125Q12.276142,18.666585353125,12,18.666585353125L4,18.666585353125Q3.7238576,18.666585353125,3.5285954,18.861847353125Q3.3333334,19.057109553125,3.3333334,19.333251953125L3.3333334,20.666585453125002ZM6.6669922,24.666829553124998C6.6669922,25.035019353125,6.3685155,25.333496053125,6.0003257,25.333496053125L2.66699219,25.333496053125C2.29880238,25.333496053125,2.00032520294,25.035019353125,2.00032520294,24.666829553124998C2.00032520294,24.298639253125,2.29880238,24.000162553125,2.66699219,24.000162553125L6.0003257,24.000162553125C6.3685155,24.000162553125,6.6669922,24.298639253125,6.6669922,24.666829553124998ZM14,24.666829553124998C14,25.035019353125,13.701523,25.333496053125,13.333333,25.333496053125L10,25.333496053125C9.6318102,25.333496053125,9.333333,25.035019353125,9.333333,24.666829553124998C9.333333,24.298639253125,9.6318102,24.000162553125,10,24.000162553125L13.333333,24.000162553125C13.701523,24.000162553125,14,24.298639253125,14,24.666829553124998Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_6286_205669"><rect x="-1" y="-1" width="26" height="26" rx="0"/></clipPath><filter id="master_svg1_6286_205674" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="-0.24827585549190126" y="-0.18020236910513046" width="1.4965517109838025" height="1.4990219502986564"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1" dx="0"/><feGaussianBlur stdDeviation="0.8999999761581421"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6499999761581421 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/></filter></defs><g clip-path="url(#master_svg0_6286_205669)"><g filter="url(#master_svg1_6286_205674)"><path d="M9,9.00000004375L9,11.50000004375L5,8.07178714375L9,4.57177734375L9,7.00000004375Q12.3137093,7.00000004375,14.6568546,9.343145343749999Q16.999999000000003,11.68629074375,16.999999000000003,15.00000034375L19.5,15.00000034375L16.071787,19.00000034375L12.5717773,15.00000034375L14.999999,15.00000034375Q14.999999,12.514717543749999,13.2426405,10.75735854375Q11.4852819,9.00000004375,9,9.00000004375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1"/><path d="M9,8.99999954375L9,11.50000004375L8,10.64294624375L5.76716352,8.729286643750001L5,8.07178684375L9,4.57177734375L9,7.00000004375Q12.3137088,7.00000004375,14.6568546,9.343145343749999Q16.999999000000003,11.68629074375,16.999999000000003,15.00000034375L19.5,15.00000034375L18.642947,16.00000034375L16.729286000000002,18.23283534375L16.071787,18.999999343749998L15.406453,18.239620343749998L13.4467793,16.00000034375L12.5717769,15.00000034375L14.999999,15.00000034375Q14.999999,14.48433204375,14.9243412,14.00000004375Q14.6353664,12.150084943749999,13.2426405,10.75735854375Q11.849915,9.36463304375,10,9.075657343749999Q9.5156684,8.99999954375,9,8.99999954375ZM10,10.09198334375L10,13.67407224375L3.4724572,8.07962084375L10,2.36800504375L10,6.04937924375Q13.0887079,6.36078454375,15.363962,8.63603834375Q17.639215,10.911292043749999,17.95062,14.00000004375L21.674072,14.00000004375L16.07962,20.52754234375L10.368004299999999,14.00000004375L13.9080153,14.00000004375Q13.6329746,12.56190684375,12.5355334,11.464465143750001Q11.4380922,10.36702344375,10,10.09198334375Z" fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_6286_205678"><rect x="23" y="-1" width="26" height="26" rx="0"/></clipPath><filter id="master_svg1_6286_205683" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="-0.24827585549190126" y="-0.18020236910513046" width="1.4965517109838025" height="1.4990219502986564"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1" dx="0"/><feGaussianBlur stdDeviation="0.8999999761581421"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6499999761581421 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/></filter></defs><g transform="matrix(-1,0,0,1,48,0)" clip-path="url(#master_svg0_6286_205678)"><g filter="url(#master_svg1_6286_205683)"><path d="M33,9.00000004375L33,11.50000004375L29,8.07178714375L33,4.57177734375L33,7.00000004375Q36.3137093,7.00000004375,38.6568546,9.343145343749999Q40.999999,11.68629074375,40.999999,15.00000034375L43.5,15.00000034375L40.071787,19.00000034375L36.5717773,15.00000034375L38.999999,15.00000034375Q38.999999,12.514717543749999,37.2426405,10.75735854375Q35.485281900000004,9.00000004375,33,9.00000004375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1"/><path d="M33,8.99999954375L33,11.50000004375L32,10.64294624375L29.76716352,8.729286643750001L29,8.07178684375L33,4.57177734375L33,7.00000004375Q36.3137088,7.00000004375,38.6568546,9.343145343749999Q40.999999,11.68629074375,40.999999,15.00000034375L43.5,15.00000034375L42.642947,16.00000034375L40.729286,18.23283534375L40.071787,18.999999343749998L39.406453,18.239620343749998L37.4467793,16.00000034375L36.5717769,15.00000034375L38.999999,15.00000034375Q38.999999,14.48433204375,38.9243412,14.00000004375Q38.6353664,12.150084943749999,37.2426405,10.75735854375Q35.849915,9.36463304375,34,9.075657343749999Q33.5156684,8.99999954375,33,8.99999954375ZM34,10.09198334375L34,13.67407224375L27.4724572,8.07962084375L34,2.36800504375L34,6.04937924375Q37.0887079,6.36078454375,39.363962,8.63603834375Q41.639215,10.911292043749999,41.95062,14.00000004375L45.674071999999995,14.00000004375L40.07962,20.52754234375L34.3680043,14.00000004375L37.9080153,14.00000004375Q37.6329746,12.56190684375,36.5355334,11.464465143750001Q35.4380922,10.36702344375,34,10.09198334375Z" fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><path d="M13.92415256875,3.99171963046875L11.82620125875,3.99171963046875C11.45808508875,3.9915862304687497,11.15966796875,3.6931691304687497,11.15966796875,3.32505292046875C11.15966796875,2.95693669046875,11.45808508875,2.65851954619875,11.82620125875,2.65851954619875L13.92415256875,2.65838623046875C14.292268968750001,2.65851954619875,14.59068586875,2.95693669046875,14.59068586875,3.32505292046875C14.59068586875,3.6931691304687497,14.292268968750001,3.9915862304687497,13.92415256875,3.99171963046875Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0.7071067690849304,0.7071067690849304,-0.7071067690849304,0.7071067690849304,5.148354105713224,-7.112453429381276)"/><path d="M4.786327345625,10.02358249296875L5.855435515625,8.87489575296875Q6.448685215625,8.237487832705181,7.319451615625,8.237487872441612L13.705051415625,8.23748779296875Q14.533478715625,8.23748779296875,15.119266015625,8.82327431296875Q15.705052015625,9.409060992968751,15.705052015625,10.23748799296875L15.705052015625,10.71807189296875Q15.705052015625,11.54649929296875,15.119266015625,12.13228579296875Q14.533478715625,12.71807189296875,13.705051415625,12.71807189296875L7.319451615625,12.71807189296875Q6.448685415625,12.71807189296875,5.855435615625,12.08066459296875L4.786327345625,10.93197729296875C4.548112235625,10.67603059296875,4.548112235625,10.279529092968751,4.786327345625,10.02358249296875ZM6.831445915625,11.17226979296875L6.185069315625,10.47778009296875L6.831445915625,9.78329059296875Q7.029196015625001,9.57082119296875,7.319451615625,9.57082119296875L13.705051415625,9.57082119296875Q13.981194515625,9.57082119296875,14.176456415625,9.76608349296875Q14.371718415625,9.96134509296875,14.371718415625,10.23748799296875L14.371718415625,10.71807189296875Q14.371718415625,10.99421499296875,14.176456415625,11.18947669296875Q13.981194515625,11.38473869296875,13.705051415625,11.38473869296875L7.319451615625,11.38473869296875Q7.029195515625,11.38473869296875,6.831445915625,11.17226979296875Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0.7071067690849304,-0.7071067690849304,0.7071067690849304,0.7071067690849304,-4.475229192368715,5.670816243637091)"/><path d="M8.853398790625,4.90680715390625Q5.024536590625,2.63791310390625,3.1998743906250002,5.067024253906251Q2.179276110625,6.4257106539062505,3.579140690625,8.87616535390625Q5.064161990625,11.47568705390625,8.155025490625,13.43705675390625C8.347627190625001,13.55943175390625,8.464362190625,13.77176275390625,8.464362190625,13.99995375390625C8.464362190625,14.36807075390625,8.165944590625,14.66648775390625,7.797828690625,14.66648775390625C7.671353790625,14.66648775390625,7.547489190625,14.63050375390625,7.440701990625,14.56273975390625Q4.070490790625,12.42426295390625,2.421402630625,9.537543753906249Q0.586676720625,6.32586625390625,2.133803840625,4.26622985390625Q4.676189390625,0.88163955390625,9.533122090625,3.7597436539062503C9.735594790625,3.8798789539062497,9.859793690625,4.09784545390625,9.859793690625,4.33327555390625C9.859793690625,4.70139165390625,9.561377490625,4.99980875390625,9.193260190625,4.99980875390625C9.073695190625,4.99980875390625,8.956329390625001,4.96764635390625,8.853398790625,4.90680715390625ZM12.191671390625,6.25573035390625C12.065336390625,6.09436415390625,11.871790390625,6.0000872539062495,11.666851390625,6.0000872539062495C11.298735590625,6.0000872539062495,11.000318490625,6.29850455390625,11.000318490625,6.66662125390625C11.000318490625,6.81557175390625,11.050210990625,6.96022985390625,11.142032590625,7.07751175390625Q12.277540390625,8.52808145390625,12.583158390625,9.87978835390625Q12.844847390625,11.03720855390625,12.339641390625,11.44075015390625Q11.312163390625,12.26146795390625,9.689505590625,10.87275795390625C9.568668390625,10.76951785390625,9.414964190625,10.71272565390625,9.256030090625,10.71272565390625C8.887912790625,10.71272565390625,8.589496590625,11.01114275390625,8.589496590625,11.37925915390625C8.589496590625,11.57401375390625,8.674674990625,11.75902655390625,8.822640890625,11.88565825390625Q11.282858890625,13.99134975390625,13.171787390625,12.48253475390625Q14.329532390625,11.55776305390625,13.883665390625,9.585746253906251Q13.512004390625,7.94193985390625,12.191671390625,6.25573035390625Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_219011"><rect x="16" y="16" width="16" height="16" rx="0"/></clipPath></defs><g transform="matrix(-1,0,0,-1,32,32)" clip-path="url(#master_svg0_6180_219011)"><rect x="16" y="16" width="16" height="16" rx="0" fill="#626999" fill-opacity="0.6000000238418579" style="opacity:0;"/><g><path d="M19.33295526622772,28.666667L20.55190646622772,29.885618C20.812109366227723,30.146009,20.812109566227722,30.568035000000002,20.551812066227722,30.828333C20.291514266227722,31.088631,19.869488866227723,31.088631,19.60919126622772,30.828333L17.25207459622772,28.471405C16.991724849227722,28.211055,16.991724849227722,27.7889452,17.25207459622772,27.528595L19.609097366227722,25.1715727C19.869488866227723,24.9113693,20.291514266227722,24.9113693,20.551812066227722,25.1716671C20.812109566227722,25.4319649,20.812109366227723,25.8539901,20.551812066227722,26.114287400000002L19.332887566227722,27.333334L25.99980246622772,27.333334Q27.380513766227722,27.333334,28.356824766227724,26.3570232Q29.333136766227724,25.380711599999998,29.333136766227724,24L29.333136766227724,22.6666675Q29.333136766227724,21.2859559,28.356824766227724,20.3096442Q27.380513766227722,19.3333334,25.99980346622772,19.3333334L19.999936666227722,19.3333334C19.631821066227722,19.3332,19.33326996622772,19.0347829,19.33326996622772,18.66666669C19.33326996622772,18.29855046,19.631821066227722,18.00016276042,19.999936666227722,18.00016276042L25.99980346622772,18Q27.93279876622772,18,29.299634766227722,19.366835000000002Q30.666468766227723,20.7336702,30.666468766227723,22.6666675L30.666468766227723,24Q30.666468766227723,25.9329977,29.299634766227722,27.2998323Q27.93279876622772,28.666667,25.99980246622772,28.666667L19.33295526622772,28.666667Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_6286_205660"><rect x="-1" y="23" width="26" height="26" rx="0"/></clipPath><filter id="master_svg1_6286_205661" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="-0.24827585549190126" y="-0.18020236910513046" width="1.4965517109838025" height="1.4990219502986564"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1" dx="0"/><feGaussianBlur stdDeviation="0.8999999761581421"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6499999761581421 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/></filter></defs><g transform="matrix(1,0,0,-1,0,48)" clip-path="url(#master_svg0_6286_205660)"><g filter="url(#master_svg1_6286_205661)"><path d="M9,33.00000004375L9,35.50000004375L5,32.07178714375L9,28.57177734375L9,31.00000004375Q12.3137093,31.00000004375,14.6568546,33.34314534375Q16.999999000000003,35.68629074375,16.999999000000003,39.00000034375L19.5,39.00000034375L16.071787,43.00000034375L12.5717773,39.00000034375L14.999999,39.00000034375Q14.999999,36.51471754375,13.2426405,34.75735854375Q11.4852819,33.00000004375,9,33.00000004375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1"/><path d="M9,32.99999954375L9,35.50000004375L8,34.64294624375L5.76716352,32.72928664375L5,32.07178684375L9,28.57177734375L9,31.00000004375Q12.3137088,31.00000004375,14.6568546,33.34314534375Q16.999999000000003,35.68629074375,16.999999000000003,39.00000034375L19.5,39.00000034375L18.642947,40.00000034375L16.729286000000002,42.23283534375L16.071787,42.99999934375L15.406453,42.23962034375L13.4467793,40.00000034375L12.5717769,39.00000034375L14.999999,39.00000034375Q14.999999,38.48433204375,14.9243412,38.00000004375Q14.6353664,36.15008494375,13.2426405,34.75735854375Q11.849915,33.36463304375,10,33.07565734375Q9.5156684,32.99999954375,9,32.99999954375ZM10,34.09198334375L10,37.674072243750004L3.4724572,32.07962084375L10,26.36800504375L10,30.04937924375Q13.0887079,30.36078454375,15.363962,32.63603834375Q17.639215,34.91129204375,17.95062,38.00000004375L21.674072,38.00000004375L16.07962,44.52754234375L10.368004299999999,38.00000004375L13.9080153,38.00000004375Q13.6329746,36.56190684375,12.5355334,35.46446514375Q11.4380922,34.36702344375,10,34.09198334375Z" fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="18.423828125" height="18.4228515625" viewBox="0 0 18.423828125 18.4228515625"><path d="M1.3637314,21.6208896015625L6.4555349,19.738862601562502L11.547301,21.6208866015625Q12.18924,21.8581606015625,12.642825,21.3456716015625Q13.096413,20.8331826015625,12.782905,20.2248246015625L7.3444271,9.6714350615625Q7.0651603,9.12951676050822,6.4555168,9.1295166015625Q5.8458743,9.12951646248499,5.5666075,9.6714341015625L0.12812716,20.224830601562502Q-0.18537746,20.8331876015625,0.2682085,21.345675601562498Q0.72179359,21.8581606015625,1.3637314,21.6208896015625ZM6.8022304,18.4455109015625L11.204543,20.072696601562498L6.4555168,10.8571892015625L1.7064869,20.0727036015625L6.10884,18.4455109015625Q6.4555368,18.3173685015625,6.8022304,18.4455109015625Z" fill-rule="evenodd" fill="#17171D" fill-opacity="1" transform="matrix(0.7071067690849304,-0.7071067690849304,0.7071067690849304,0.7071067690849304,-6.455542987438093,2.6739736141244066)"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="24" height="24" viewBox="0 0 24 24"><defs><clipPath id="master_svg0_6286_205687"><rect x="23" y="23" width="26" height="26" rx="0"/></clipPath><filter id="master_svg1_6286_205692" filterUnits="objectBoundingBox" color-interpolation-filters="sRGB" x="-0.24827585549190126" y="-0.18020236910513046" width="1.4965517109838025" height="1.4990219502986564"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dy="1" dx="0"/><feGaussianBlur stdDeviation="0.8999999761581421"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.6499999761581421 0"/><feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/><feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/></filter></defs><g transform="matrix(-1,0,0,-1,48,48)" clip-path="url(#master_svg0_6286_205687)"><g filter="url(#master_svg1_6286_205692)"><path d="M33,33.00000004375L33,35.50000004375L29,32.07178714375L33,28.57177734375L33,31.00000004375Q36.3137093,31.00000004375,38.6568546,33.34314534375Q40.999999,35.68629074375,40.999999,39.00000034375L43.5,39.00000034375L40.071787,43.00000034375L36.5717773,39.00000034375L38.999999,39.00000034375Q38.999999,36.51471754375,37.2426405,34.75735854375Q35.485281900000004,33.00000004375,33,33.00000004375Z" fill-rule="evenodd" fill="#000000" fill-opacity="1"/><path d="M33,32.99999954375L33,35.50000004375L32,34.64294624375L29.76716352,32.72928664375L29,32.07178684375L33,28.57177734375L33,31.00000004375Q36.3137088,31.00000004375,38.6568546,33.34314534375Q40.999999,35.68629074375,40.999999,39.00000034375L43.5,39.00000034375L42.642947,40.00000034375L40.729286,42.23283534375L40.071787,42.99999934375L39.406453,42.23962034375L37.4467793,40.00000034375L36.5717769,39.00000034375L38.999999,39.00000034375Q38.999999,38.48433204375,38.9243412,38.00000004375Q38.6353664,36.15008494375,37.2426405,34.75735854375Q35.849915,33.36463304375,34,33.07565734375Q33.5156684,32.99999954375,33,32.99999954375ZM34,34.09198334375L34,37.674072243750004L27.4724572,32.07962084375L34,26.36800504375L34,30.04937924375Q37.0887079,30.36078454375,39.363962,32.63603834375Q41.639215,34.91129204375,41.95062,38.00000004375L45.674071999999995,38.00000004375L40.07962,44.52754234375L34.3680043,38.00000004375L37.9080153,38.00000004375Q37.6329746,36.56190684375,36.5355334,35.46446514375Q35.4380922,34.36702344375,34,34.09198334375Z" fill-rule="evenodd" fill="#FFFFFF" fill-opacity="1"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="none" version="1.1" width="16" height="16" viewBox="0 0 16 16"><defs><clipPath id="master_svg0_6180_213789"><rect x="0" y="0" width="16" height="16" rx="0"/></clipPath></defs><g clip-path="url(#master_svg0_6180_213789)"><rect x="13.33349609375" y="3.33331298828125" width="10.666666984558105" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,26.6669921875,6.6666259765625)"/><rect x="11.33349609375" y="14" width="6.6666669845581055" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1" transform="matrix(-1,0,0,-1,22.6669921875,28)"/><rect x="7.33349609375" y="13.3333740234375" width="10.666666984558105" height="1.3333333730697632" rx="0" fill="#17171D" fill-opacity="1" transform="matrix(0,-1,1,0,-5.9998779296875,20.6668701171875)"/><rect x="2.66650390625" y="5.3333740234375" width="3.3333334922790527" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1" transform="matrix(0,-1,1,0,-2.6668701171875,7.9998779296875)"/><rect x="12" y="5.3333740234375" width="3.3333334922790527" height="1.3333333730697632" rx="0.6666666865348816" fill="#17171D" fill-opacity="1" transform="matrix(0,-1,1,0,6.6666259765625,17.3333740234375)"/></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More