初始化模版工程
1
components/image-editor/components/canvas/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DOMINO_EL_PREFIX = 'domino-el-'
|
||||
10
components/image-editor/components/canvas/dom-utils.ts
Normal 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)
|
||||
}
|
||||
236
components/image-editor/components/canvas/domino-anchor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
280
components/image-editor/components/canvas/domino-canvas.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
112
components/image-editor/components/canvas/domino-hooks.ts
Normal 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)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
637
components/image-editor/components/canvas/domino-store.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
251
components/image-editor/components/canvas/domino.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
189
components/image-editor/components/canvas/element-renderer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
12
components/image-editor/components/canvas/index.ts
Normal 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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
227
components/image-editor/components/canvas/math.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
30
components/image-editor/components/canvas/placeholder.tsx
Normal 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)
|
||||
484
components/image-editor/components/canvas/selection-overlay.tsx
Normal 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'
|
||||
108
components/image-editor/components/canvas/text-render.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
130
components/image-editor/components/canvas/use-domino-anchor.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
115
components/image-editor/components/canvas/use-domino-instance.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
115
components/image-editor/components/canvas/use-gestures.ts
Normal 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 },
|
||||
},
|
||||
)
|
||||
}
|
||||
839
components/image-editor/components/canvas/use-interactions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
33
components/image-editor/components/canvas/use-transform.ts
Normal 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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
142
components/image-editor/components/editor/bottom-toolbar.tsx
Normal 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)
|
||||
396
components/image-editor/components/editor/domi-board.tsx
Normal 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)
|
||||
145
components/image-editor/components/editor/draggable.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
169
components/image-editor/components/editor/image-text-editor.tsx
Normal 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)
|
||||
209
components/image-editor/components/editor/inline-mark-editor.tsx
Normal 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'
|
||||
23
components/image-editor/components/editor/loading.tsx
Normal 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>
|
||||
))
|
||||
232
components/image-editor/components/editor/main.tsx
Normal 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
|
||||
170
components/image-editor/components/editor/selection-overlay.tsx
Normal 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)
|
||||
382
components/image-editor/components/editor/text-toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
115
components/image-editor/components/editor/top-bar.tsx
Normal 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)
|
||||
57
components/image-editor/components/ui/button.tsx
Normal 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 }
|
||||
209
components/image-editor/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
37
components/image-editor/components/ui/error-boundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
48
components/image-editor/components/ui/icon-mapping.ts
Normal file
108
components/image-editor/components/ui/icon.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
25
components/image-editor/components/ui/input.tsx
Normal 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 }
|
||||
48
components/image-editor/components/ui/local-icon.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
39
components/image-editor/components/ui/tooltip.tsx
Normal 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,
|
||||
}
|
||||
6
components/image-editor/consts.ts
Normal 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
|
||||
265
components/image-editor/hooks/use-download-image-group.ts
Normal 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 }
|
||||
}
|
||||
227
components/image-editor/hooks/use-element-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
436
components/image-editor/hooks/use-image-edit-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
162
components/image-editor/hooks/use-layout.ts
Normal 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 }
|
||||
}
|
||||
83
components/image-editor/hooks/use-local-fonts.ts
Normal 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 }
|
||||
}
|
||||
80
components/image-editor/hooks/use-paste-handler.ts
Normal 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
|
||||
}
|
||||
|
||||
156
components/image-editor/hooks/use-persistence.ts
Normal 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 }
|
||||
}
|
||||
200
components/image-editor/hooks/use-shortcuts.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
35
components/image-editor/hooks/use-zoom-actions.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
1
components/image-editor/icons/artboard-gray.svg
Normal 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 |
1
components/image-editor/icons/artboard-mode.svg
Normal 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 |
1
components/image-editor/icons/auto.svg
Normal 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 |
5
components/image-editor/icons/back.svg
Normal 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 |
1
components/image-editor/icons/board-close.svg
Normal 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 |
1
components/image-editor/icons/board-zoom-in.svg
Normal 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 |
1
components/image-editor/icons/board-zoom-out.svg
Normal 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 |
1
components/image-editor/icons/bold.svg
Normal 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 |
1
components/image-editor/icons/check.svg
Normal 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 |
1
components/image-editor/icons/chevron-right.svg
Normal 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 |
1
components/image-editor/icons/circle.svg
Normal 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 |
1
components/image-editor/icons/close.svg
Normal 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 |
1
components/image-editor/icons/download.svg
Normal 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 |
1
components/image-editor/icons/dropdown.svg
Normal 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 |
1
components/image-editor/icons/edit-elements.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
1
components/image-editor/icons/edit-text.svg
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
1
components/image-editor/icons/error.svg
Normal 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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
1
components/image-editor/icons/image-editor-text-more.svg
Normal file
|
After Width: | Height: | Size: 10 KiB |
1
components/image-editor/icons/image-matting.svg
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
1
components/image-editor/icons/image-mode.svg
Normal 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 |
1
components/image-editor/icons/italic.svg
Normal 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 |
1
components/image-editor/icons/layer-down.svg
Normal 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 |
1
components/image-editor/icons/layer-up.svg
Normal 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 |
24
components/image-editor/icons/more.svg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
1
components/image-editor/icons/move-to-bottom.svg
Normal 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 |
1
components/image-editor/icons/move-to-top.svg
Normal 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 |
1
components/image-editor/icons/ne-rotate.svg
Normal 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 |
1
components/image-editor/icons/nw-rotate.svg
Normal 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 |
1
components/image-editor/icons/pan-mode.svg
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
1
components/image-editor/icons/partial-redraw.svg
Normal 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 |
1
components/image-editor/icons/redo.svg
Normal 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 |
1
components/image-editor/icons/se-rotate.svg
Normal 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 |
1
components/image-editor/icons/select-mode.svg
Normal 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 |
1
components/image-editor/icons/sw-rotate.svg
Normal 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 |
1
components/image-editor/icons/text-mode.svg
Normal 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 |