惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
T
The Blog of Author Tim Ferriss
F
Fortinet All Blogs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
阮一峰的网络日志
阮一峰的网络日志
The GitHub Blog
The GitHub Blog
Y
Y Combinator Blog
MyScale Blog
MyScale Blog
雷峰网
雷峰网
博客园 - 叶小钗
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
GbyAI
GbyAI
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
博客园 - 三生石上(FineUI控件)
云风的 BLOG
云风的 BLOG
V
V2EX
宝玉的分享
宝玉的分享
酷 壳 – CoolShell
酷 壳 – CoolShell
N
Netflix TechBlog - Medium
Vercel News
Vercel News
美团技术团队
人人都是产品经理
人人都是产品经理
The Cloudflare Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
image cropper на solidjs
собачья будк · 2026-04-24 · via DEV Community

собачья будка

иногда кажется, что кроп изображения — это просто ui-компонент: выделил область, обрезал, сохранил.

но когда ты начинаешь делать это руками через canvas, drag’n’drop и пересчёт координат с учётом scale — внезапно получается маленький графический редактор.

в этой статье — разбор image cropper, который я собрал на solidjs: от canvas-рендера до экспорта файла.


контекст

задача была простой на словах:

  • загрузить изображение
  • дать пользователю выбрать область
  • добавить зум
  • сохранить результат как файл

но почти сразу стало понятно, что это не dom-задача, а работа с пикселями.


сам компонент

это основной компонент кроппера. он держит всё состояние внутри: canvas, drag, scale, загрузку и экспорт.

import { UploadFile } from '@solid-primitives/upload'
import { clsx } from 'clsx'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import { useLocalize } from '~/context/localize'
import { Button } from '../Button'

import styles from './ImageCropper.module.scss'

interface CropperProps {
  uploadFile: UploadFile
  onSave: (arg0: any) => void
  onDecline?: () => void
}

export const ImageCropper = (props: CropperProps) => {
  let canvasRef: HTMLCanvasElement | undefined
  let imageRef: HTMLImageElement | undefined
  let containerRef: HTMLDivElement | undefined

  const { t } = useLocalize()

  const [isLoading, setIsLoading] = createSignal(false)
  const [cropData, setCropData] = createSignal({
    x: 0,
    y: 0,
    width: 200,
    height: 200
  })

  const [isDragging, setIsDragging] = createSignal(false)
  const [dragStart, setDragStart] = createSignal({ x: 0, y: 0 })
  const [imageLoaded, setImageLoaded] = createSignal(false)
  const [scale, setScale] = createSignal(1)

  // логика ниже
}

Enter fullscreen mode Exit fullscreen mode


стили: это уже не просто ui

.cropperContainer {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  padding: 1rem;
  background: var(--black-50);
  border-radius: var(--comment-radius-md);
}

Enter fullscreen mode Exit fullscreen mode

идея простая:

  • центрированная рабочая зона
  • ощущение инструмента, а не формы
  • минимум отвлекающего ui

canvas как рабочая поверхность

.cropperCanvas {
  display: flex;
  justify-content: center;
  align-items: center;
  background: var(--background-color);
  border-radius: var(--border-radius);
  padding: 10px;
  box-shadow: 0 2px 8px var(--shadow-color-medium);
}

Enter fullscreen mode Exit fullscreen mode

canvas визуально отделён — как холст в редакторе.


grab vs dragging

.cropperCanvas canvas {
  cursor: grab;
}

.cropperCanvas canvas.dragging {
  cursor: grabbing;
}

Enter fullscreen mode Exit fullscreen mode


zoom контролы

.zoomControl {
  background-color: rgba(0, 0, 0, 0.5);
  border-radius: 50%;
  width: 30px;
  height: 30px;
  font-size: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  cursor: pointer;
}

Enter fullscreen mode Exit fullscreen mode


адаптивность

@media (max-width: 768px) {
  .cropperContainer {
    padding: 0.5rem;
  }

  .cropperCanvas canvas {
    max-width: calc(100vw - 2rem);
    max-height: 300px;
  }
}

Enter fullscreen mode Exit fullscreen mode


как работает рендер

const drawImage = () => {
  const ctx = canvasRef.getContext('2d')
  if (!ctx || !imageRef) return

  const img = imageRef
  const currentScale = scale()

  ctx.clearRect(0, 0, canvas.width, canvas.height)

  const displayWidth = img.naturalWidth * currentScale
  const displayHeight = img.naturalHeight * currentScale

  const offsetX = (canvas.width - displayWidth) / 2
  const offsetY = (canvas.height - displayHeight) / 2

  ctx.drawImage(img, offsetX, offsetY, displayWidth, displayHeight)

  ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  const crop = cropData()
  ctx.clearRect(crop.x, crop.y, crop.width, crop.height)

  ctx.strokeStyle = '#fff'
  ctx.strokeRect(crop.x, crop.y, crop.width, crop.height)
}

Enter fullscreen mode Exit fullscreen mode


drag логика

const handleMouseDown = (e: MouseEvent) => {
  const rect = canvasRef?.getBoundingClientRect()
  if (!rect) return

  const x = e.clientX - rect.left
  const y = e.clientY - rect.top

  const crop = cropData()

  if (
    x >= crop.x &&
    x <= crop.x + crop.width &&
    y >= crop.y &&
    y <= crop.y + crop.height
  ) {
    setIsDragging(true)
    setDragStart({ x: x - crop.x, y: y - crop.y })
  }
}

Enter fullscreen mode Exit fullscreen mode


перемещение области

const handleMouseMove = (e: MouseEvent) => {
  if (!isDragging() || !canvasRef) return

  const rect = canvasRef.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top

  const drag = dragStart()

  const newX = Math.max(0, Math.min(canvasRef.width - cropData().width, x - drag.x))
  const newY = Math.max(0, Math.min(canvasRef.height - cropData().height, y - drag.y))

  setCropData({ ...cropData(), x: newX, y: newY })
  drawImage()
}

Enter fullscreen mode Exit fullscreen mode


экспорт кропа

const cropImage = () => {
  const crop = cropData()
  const currentScale = scale()

  const img = imageRef

  const displayWidth = img.naturalWidth * currentScale
  const displayHeight = img.naturalHeight * currentScale

  const offsetX = (canvas.width - displayWidth) / 2
  const offsetY = (canvas.height - displayHeight) / 2

  const sourceX = (crop.x - offsetX) / currentScale
  const sourceY = (crop.y - offsetY) / currentScale
  const sourceWidth = crop.width / currentScale
  const sourceHeight = crop.height / currentScale

  const cropCanvas = document.createElement('canvas')
  cropCanvas.width = 300
  cropCanvas.height = 300

  const ctx = cropCanvas.getContext('2d')

  ctx.drawImage(
    img,
    sourceX,
    sourceY,
    sourceWidth,
    sourceHeight,
    0,
    0,
    300,
    300
  )

  return cropCanvas
}

Enter fullscreen mode Exit fullscreen mode


сохранение файла

const handleSave = () => {
  const croppedCanvas = cropImage()

  croppedCanvas.toBlob(blob => {
    const file = new File([blob], `cropped-${props.uploadFile.name}`, {
      type: 'image/png'
    })

    props.onSave(file)
  }, 'image/png')
}

Enter fullscreen mode Exit fullscreen mode


итог

в итоге это не просто кроппер.

это:

  • canvas-рендеринг
  • ручная система координат
  • drag & zoom
  • экспорт в файл

и самое интересное — чем дальше ты заходишь, тем меньше это похоже на ui-компонент и тем больше на мини-редактор изображений внутри браузера.

source code