From 4ae2456712faaea1795d3aea831050d6f76ef87b Mon Sep 17 00:00:00 2001 From: Nelson Lai Date: Tue, 28 Oct 2025 00:17:41 +0800 Subject: [PATCH 1/2] docs: add image cropper docs --- packages/docs/data/accessibility.json | 44 ++ packages/docs/data/api.json | 387 +++++++++++++++++- packages/docs/data/css-vars.json | 46 ++- packages/docs/data/data-attr.json | 149 +++++-- pnpm-lock.yaml | 16 +- website/data/components/image-cropper.mdx | 227 ++++++++++ .../react/image-cropper/installation.mdx | 5 + .../snippets/react/image-cropper/usage.mdx | 44 ++ .../solid/image-cropper/installation.mdx | 5 + .../snippets/solid/image-cropper/usage.mdx | 46 +++ .../svelte/image-cropper/installation.mdx | 5 + .../snippets/svelte/image-cropper/usage.mdx | 41 ++ .../vue/image-cropper/installation.mdx | 5 + .../data/snippets/vue/image-cropper/usage.mdx | 46 +++ website/demos/image-cropper.tsx | 34 ++ website/demos/index.tsx | 8 + website/package.json | 2 + website/sidebar.config.ts | 1 + website/styles/machines/image-cropper.css | 98 +++++ website/styles/machines/index.css | 1 + 20 files changed, 1160 insertions(+), 50 deletions(-) create mode 100644 website/data/components/image-cropper.mdx create mode 100644 website/data/snippets/react/image-cropper/installation.mdx create mode 100644 website/data/snippets/react/image-cropper/usage.mdx create mode 100644 website/data/snippets/solid/image-cropper/installation.mdx create mode 100644 website/data/snippets/solid/image-cropper/usage.mdx create mode 100644 website/data/snippets/svelte/image-cropper/installation.mdx create mode 100644 website/data/snippets/svelte/image-cropper/usage.mdx create mode 100644 website/data/snippets/vue/image-cropper/installation.mdx create mode 100644 website/data/snippets/vue/image-cropper/usage.mdx create mode 100644 website/demos/image-cropper.tsx create mode 100644 website/styles/machines/image-cropper.css diff --git a/packages/docs/data/accessibility.json b/packages/docs/data/accessibility.json index f5d0b3d85a..cbe1e89773 100644 --- a/packages/docs/data/accessibility.json +++ b/packages/docs/data/accessibility.json @@ -607,6 +607,50 @@ } ] }, + "image-cropper": { + "keyboard": [ + { + "keys": ["ArrowUp"], + "description": "Moves the crop selection upward by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`." + }, + { + "keys": ["ArrowDown"], + "description": "Moves the crop selection downward by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`." + }, + { + "keys": ["ArrowLeft"], + "description": "Moves the crop selection to the left by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`." + }, + { + "keys": ["ArrowRight"], + "description": "Moves the crop selection to the right by the configured nudge step. Hold Shift for the `nudgeStepShift` value or Ctrl/Cmd for `nudgeStepCtrl`." + }, + { + "keys": ["Alt + ArrowUp"], + "description": "Resizes the crop vertically from the bottom handle, reducing the height. Hold Shift or Ctrl/Cmd for the larger nudge steps." + }, + { + "keys": ["Alt + ArrowDown"], + "description": "Resizes the crop vertically from the bottom handle, increasing the height. Hold Shift or Ctrl/Cmd for the larger nudge steps." + }, + { + "keys": ["Alt + ArrowLeft"], + "description": "Resizes the crop horizontally from the right handle, reducing the width. Hold Shift or Ctrl/Cmd for the larger nudge steps." + }, + { + "keys": ["Alt + ArrowRight"], + "description": "Resizes the crop horizontally from the right handle, increasing the width. Hold Shift or Ctrl/Cmd for the larger nudge steps." + }, + { + "keys": ["+"], + "description": "Zooms in on the image. The `=` key performs the same action on keyboards where both symbols share a key." + }, + { + "keys": ["-"], + "description": "Zooms out of the image. The `_` key performs the same action on keyboards where both symbols share a key." + } + ] + }, "number-input": { "keyboard": [ { diff --git a/packages/docs/data/api.json b/packages/docs/data/api.json index e71b0ce1f0..26cd119159 100644 --- a/packages/docs/data/api.json +++ b/packages/docs/data/api.json @@ -94,7 +94,7 @@ }, "context": { "ids": { - "type": "Partial<{ root: string; thumb: string; hiddenInput: string; control: string; valueText: string; }>", + "type": "Partial<{ root: string; thumb: string; hiddenInput: string; control: string; valueText: string; label: string; }>", "description": "The ids of the elements in the machine.\nUseful for composition." }, "step": { @@ -135,6 +135,14 @@ "type": "string", "description": "The name of the slider. Useful for form submission." }, + "aria-label": { + "type": "string", + "description": "The accessible label for the slider thumb." + }, + "aria-labelledby": { + "type": "string", + "description": "The id of the element that labels the slider thumb." + }, "dir": { "type": "\"ltr\" | \"rtl\"", "description": "The document's text/writing direction.", @@ -301,17 +309,37 @@ "type": "boolean", "description": "Whether the bottom sheet is open." }, - "activeSnapPoint": { - "type": "string | number", - "description": "The currently active snap point." + "dragging": { + "type": "boolean", + "description": "Whether the bottom sheet is currently being dragged." }, "setOpen": { "type": "(open: boolean) => void", "description": "Function to open or close the menu." }, + "snapPoints": { + "type": "(string | number)[]", + "description": "The snap points of the bottom sheet." + }, + "activeSnapPoint": { + "type": "string | number", + "description": "The currently active snap point." + }, "setActiveSnapPoint": { "type": "(snapPoint: string | number) => void", "description": "Function to set the active snap point." + }, + "getOpenPercentage": { + "type": "() => number", + "description": "Get the current open percentage of the bottom sheet." + }, + "getActiveSnapIndex": { + "type": "() => number", + "description": "Get the index of the currently active snap point." + }, + "getContentHeight": { + "type": "() => number", + "description": "Get the current height of the bottom sheet content." } }, "context": { @@ -344,7 +372,8 @@ }, "restoreFocus": { "type": "boolean", - "description": "Whether to restore focus to the element that had focus before the sheet was opened." + "description": "Whether to restore focus to the element that had focus before the sheet was opened.", + "defaultValue": "true" }, "role": { "type": "\"dialog\" | \"alertdialog\"", @@ -353,7 +382,7 @@ }, "open": { "type": "boolean", - "description": "Whether the bottom sheet is resizable." + "description": "Whether the bottom sheet is open." }, "defaultOpen": { "type": "boolean", @@ -381,7 +410,7 @@ "swipeVelocityThreshold": { "type": "number", "description": "The threshold velocity (in pixels/s) for closing the bottom sheet.", - "defaultValue": "5" + "defaultValue": "700" }, "closeThreshold": { "type": "number", @@ -797,6 +826,14 @@ "type": "boolean", "description": "Whether the collapsible is disabled." }, + "collapsedHeight": { + "type": "string | number", + "description": "The height of the content when collapsed." + }, + "collapsedWidth": { + "type": "string | number", + "description": "The width of the content when collapsed." + }, "id": { "type": "string", "description": "The unique identifier of the machine." @@ -1177,6 +1214,11 @@ "type": "boolean", "description": "Whether to allow typing custom values in the input" }, + "alwaysSubmitOnEnter": { + "type": "boolean", + "description": "Whether to always submit on Enter key press, even if popup is open.\nUseful for single-field autocomplete forms where Enter should submit the form.", + "defaultValue": "false" + }, "loopFocus": { "type": "boolean", "description": "Whether to loop the keyboard navigation through the items", @@ -2248,7 +2290,7 @@ "openDelay": { "type": "number", "description": "The duration from when the mouse enters the trigger until the hover card opens.", - "defaultValue": "700" + "defaultValue": "600" }, "closeDelay": { "type": "number", @@ -2298,6 +2340,177 @@ } } }, + "image-cropper": { + "api": { + "setZoom": { + "type": "(zoom: number) => void", + "description": "Function to set the zoom level of the image." + }, + "setRotation": { + "type": "(rotation: number) => void", + "description": "Function to set the rotation of the image." + }, + "setFlip": { + "type": "(flip: Partial) => void", + "description": "Function to set the flip state of the image." + }, + "flipHorizontally": { + "type": "(value?: boolean) => void", + "description": "Function to flip the image horizontally. Pass a boolean to set explicitly or omit to toggle." + }, + "flipVertically": { + "type": "(value?: boolean) => void", + "description": "Function to flip the image vertically. Pass a boolean to set explicitly or omit to toggle." + }, + "resize": { + "type": "(handlePosition: HandlePosition, delta: number) => void", + "description": "Function to resize the crop area from a handle programmatically." + }, + "getCroppedImage": { + "type": "(options?: GetCroppedImageOptions) => Promise", + "description": "Function to get the cropped image with all transformations applied.\nReturns a Promise that resolves to either a Blob or data URL." + } + }, + "context": { + "ids": { + "type": "Partial<{ root: string; viewport: string; image: string; selection: string; handle: (position: string) => string; }>", + "description": "The ids of the image cropper elements" + }, + "translations": { + "type": "IntlTranslations", + "description": "Specifies the localized strings that identify accessibility elements and their states." + }, + "initialCrop": { + "type": "Rect", + "description": "The initial rectangle of the crop area.\nIf not provided, a smart default will be computed based on viewport size and aspect ratio." + }, + "minWidth": { + "type": "number", + "description": "The minimum width of the crop area", + "defaultValue": "40" + }, + "minHeight": { + "type": "number", + "description": "The minimum height of the crop area", + "defaultValue": "40" + }, + "maxWidth": { + "type": "number", + "description": "The maximum width of the crop area", + "defaultValue": "Infinity" + }, + "maxHeight": { + "type": "number", + "description": "The maximum height of the crop area", + "defaultValue": "Infinity" + }, + "aspectRatio": { + "type": "number", + "description": "The aspect ratio to maintain for the crop area (width / height).\nFor example, an aspect ratio of 16 / 9 will maintain a width to height ratio of 16:9.\nIf not provided, the crop area can be freely resized." + }, + "cropShape": { + "type": "\"rectangle\" | \"circle\"", + "description": "The shape of the crop area.", + "defaultValue": "\"rectangle\"" + }, + "zoom": { + "type": "number", + "description": "The controlled zoom level of the image." + }, + "rotation": { + "type": "number", + "description": "The controlled rotation of the image in degrees (0 - 360)." + }, + "flip": { + "type": "FlipState", + "description": "The controlled flip state of the image." + }, + "defaultZoom": { + "type": "number", + "description": "The initial zoom factor to apply to the image.", + "defaultValue": "1" + }, + "defaultRotation": { + "type": "number", + "description": "The initial rotation to apply to the image in degrees.", + "defaultValue": "0" + }, + "defaultFlip": { + "type": "FlipState", + "description": "The initial flip state to apply to the image.", + "defaultValue": "{ horizontal: false, vertical: false }" + }, + "zoomStep": { + "type": "number", + "description": "The amount of zoom applied per wheel step.", + "defaultValue": "0.1" + }, + "zoomSensitivity": { + "type": "number", + "description": "Controls how responsive pinch-to-zoom is.", + "defaultValue": "2" + }, + "minZoom": { + "type": "number", + "description": "The minimum zoom factor allowed.", + "defaultValue": "1" + }, + "maxZoom": { + "type": "number", + "description": "The maximum zoom factor allowed.", + "defaultValue": "5" + }, + "nudgeStep": { + "type": "number", + "description": "The base nudge step for keyboard arrow keys (in pixels).", + "defaultValue": "1" + }, + "nudgeStepShift": { + "type": "number", + "description": "The nudge step when Shift key is held (in pixels).", + "defaultValue": "10" + }, + "nudgeStepCtrl": { + "type": "number", + "description": "The nudge step when Ctrl/Cmd key is held (in pixels).", + "defaultValue": "50" + }, + "onZoomChange": { + "type": "(details: ZoomChangeDetails) => void", + "description": "Callback fired when the zoom level changes." + }, + "onRotationChange": { + "type": "(details: RotationChangeDetails) => void", + "description": "Callback fired when the rotation changes." + }, + "onFlipChange": { + "type": "(details: FlipChangeDetails) => void", + "description": "Callback fired when the flip state changes." + }, + "onCropChange": { + "type": "(details: CropChangeDetails) => void", + "description": "Callback fired when the crop area changes." + }, + "fixedCropArea": { + "type": "boolean", + "description": "Whether the crop area is fixed in size and position.", + "defaultValue": "false" + }, + "dir": { + "type": "\"ltr\" | \"rtl\"", + "description": "The document's text/writing direction.", + "defaultValue": "\"ltr\"" + }, + "id": { + "type": "string", + "description": "The unique identifier of the machine." + }, + "getRootNode": { + "type": "() => ShadowRoot | Node | Document", + "description": "A root node to correctly resolve document in custom environments. E.x.: Iframes, Electron." + } + } + }, "listbox": { "api": { "empty": { @@ -2457,6 +2670,130 @@ } } }, + "marquee": { + "api": { + "paused": { + "type": "boolean", + "description": "Whether the marquee is currently paused." + }, + "orientation": { + "type": "\"horizontal\" | \"vertical\"", + "description": "The current orientation of the marquee." + }, + "side": { + "type": "Side", + "description": "The current side/direction of the marquee." + }, + "multiplier": { + "type": "number", + "description": "The multiplier for auto-fill. Indicates how many times to duplicate content.\nWhen autoFill is enabled and content is smaller than container, this returns\nthe number of additional copies needed. Otherwise returns 1." + }, + "contentCount": { + "type": "number", + "description": "The total number of content elements to render (original + clones).\nUse this value when rendering your content in a loop." + }, + "pause": { + "type": "VoidFunction", + "description": "Pause the marquee animation." + }, + "resume": { + "type": "VoidFunction", + "description": "Resume the marquee animation." + }, + "togglePause": { + "type": "VoidFunction", + "description": "Toggle the pause state." + }, + "restart": { + "type": "VoidFunction", + "description": "Restart the marquee animation from the beginning." + } + }, + "context": { + "ids": { + "type": "Partial<{ root: string; viewport: string; content: (index: number) => string; }>", + "description": "The ids of the elements in the marquee. Useful for composition." + }, + "translations": { + "type": "IntlTranslations", + "description": "The localized messages to use." + }, + "side": { + "type": "Side", + "description": "The side/direction the marquee scrolls towards.", + "defaultValue": "\"start\"" + }, + "speed": { + "type": "number", + "description": "The speed of the marquee animation in pixels per second.", + "defaultValue": "50" + }, + "delay": { + "type": "number", + "description": "The delay before the animation starts (in seconds).", + "defaultValue": "0" + }, + "loopCount": { + "type": "number", + "description": "The number of times to loop the animation (0 = infinite).", + "defaultValue": "0" + }, + "spacing": { + "type": "string", + "description": "The spacing between marquee items.", + "defaultValue": "\"1rem\"" + }, + "autoFill": { + "type": "boolean", + "description": "Whether to automatically duplicate content to fill the container.", + "defaultValue": "false" + }, + "pauseOnInteraction": { + "type": "boolean", + "description": "Whether to pause the marquee on user interaction (hover, focus).", + "defaultValue": "false" + }, + "reverse": { + "type": "boolean", + "description": "Whether to reverse the animation direction.", + "defaultValue": "false" + }, + "paused": { + "type": "boolean", + "description": "Whether the marquee is paused." + }, + "defaultPaused": { + "type": "boolean", + "description": "Whether the marquee is paused by default.", + "defaultValue": "false" + }, + "onPauseChange": { + "type": "(details: PauseStatusDetails) => void", + "description": "Function called when the pause status changes." + }, + "onLoopComplete": { + "type": "() => void", + "description": "Function called when the marquee completes one loop iteration." + }, + "onComplete": { + "type": "() => void", + "description": "Function called when the marquee completes all loops and stops.\nOnly fires for finite loops (loopCount > 0)." + }, + "dir": { + "type": "\"ltr\" | \"rtl\"", + "description": "The document's text/writing direction.", + "defaultValue": "\"ltr\"" + }, + "id": { + "type": "string", + "description": "The unique identifier of the machine." + }, + "getRootNode": { + "type": "() => ShadowRoot | Node | Document", + "description": "A root node to correctly resolve document in custom environments. E.x.: Iframes, Electron." + } + } + }, "menu": { "api": { "open": { @@ -2975,6 +3312,10 @@ "description": "The type of the trigger element", "defaultValue": "\"button\"" }, + "getPageUrl": { + "type": "(details: PageUrlDetails) => string", + "description": "Function to generate href attributes for pagination links.\nOnly used when `type` is set to \"link\"." + }, "dir": { "type": "\"ltr\" | \"rtl\"", "description": "The document's text/writing direction.", @@ -5482,6 +5823,18 @@ "expandParent": { "type": "(value: string) => void", "description": "Function to expand the parent node of the focused node" + }, + "startRenaming": { + "type": "(value: string) => void", + "description": "Function to start renaming a node by value" + }, + "submitRenaming": { + "type": "(value: string, label: string) => void", + "description": "Function to submit the rename and update the node label" + }, + "cancelRenaming": { + "type": "() => void", + "description": "Function to cancel renaming without changes" } }, "context": { @@ -5546,6 +5899,22 @@ "type": "(details: CheckedChangeDetails) => void", "description": "Called when the checked value changes" }, + "canRename": { + "type": "(node: T, indexPath: IndexPath) => boolean", + "description": "Function to determine if a node can be renamed" + }, + "onRenameStart": { + "type": "(details: RenameStartDetails) => void", + "description": "Called when a node starts being renamed" + }, + "onBeforeRename": { + "type": "(details: RenameCompleteDetails) => boolean", + "description": "Called before a rename is completed. Return false to prevent the rename." + }, + "onRenameComplete": { + "type": "(details: RenameCompleteDetails) => void", + "description": "Called when a node label rename is completed" + }, "onLoadChildrenComplete": { "type": "(details: LoadChildrenCompleteDetails) => void", "description": "Called when a node finishes loading children" @@ -5583,4 +5952,4 @@ } } } -} +} \ No newline at end of file diff --git a/packages/docs/data/css-vars.json b/packages/docs/data/css-vars.json index 3f7bb64289..baf1e0510a 100644 --- a/packages/docs/data/css-vars.json +++ b/packages/docs/data/css-vars.json @@ -1,12 +1,4 @@ { - "tree-view": { - "Item": { - "--depth": "The depth value for the Item" - }, - "Branch": { - "--depth": "The depth value for the Branch" - } - }, "tour": { "Backdrop": { "--tour-layer": "The tour layer value for the Backdrop", @@ -37,6 +29,14 @@ "--nested-layer-count": "The number of nested tours" } }, + "tree-view": { + "Item": { + "--depth": "The depth value for the Item" + }, + "Branch": { + "--depth": "The depth value for the Branch" + } + }, "timer": { "Item": { "--value": "The current value" @@ -103,6 +103,29 @@ "--column-count": "The column count value for the Content" } }, + "image-cropper": { + "Root": { + "--crop-width": "The width of the Root", + "--crop-height": "The height of the Root", + "--crop-x": "The crop x value for the Root", + "--crop-y": "The crop y value for the Root" + }, + "Selection": { + "--width": "The width of the element", + "--height": "The height of the element", + "--x": "The x position for transform", + "--y": "The y position for transform" + } + }, + "marquee": { + "Root": { + "--marquee-duration": "The marquee duration value for the Root", + "--marquee-spacing": "The marquee spacing value for the Root", + "--marquee-delay": "The marquee delay value for the Root", + "--marquee-loop-count": "The marquee loop count value for the Root", + "--marquee-translate": "The marquee translate value for the Root" + } + }, "floating-panel": { "Positioner": { "--width": "The width of the element", @@ -166,7 +189,9 @@ "collapsible": { "Content": { "--height": "The height of the element", - "--width": "The width of the element" + "--width": "The width of the element", + "--collapsed-height": "The height of the Content", + "--collapsed-width": "The width of the Content" } }, "carousel": { @@ -224,7 +249,6 @@ "--reference-height": "The height of the root" } }, - "select": { "Arrow": { "--arrow-size": "The size of the arrow", @@ -393,4 +417,4 @@ "--layer-index": "The index of the dismissable in the layer stack" } } -} +} \ No newline at end of file diff --git a/packages/docs/data/data-attr.json b/packages/docs/data/data-attr.json index 44393ffda0..9138053e61 100644 --- a/packages/docs/data/data-attr.json +++ b/packages/docs/data/data-attr.json @@ -161,7 +161,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"indeterminate\" | \"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Label": { "data-active": "Present when active or pressed", @@ -171,7 +172,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"indeterminate\" | \"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Control": { "data-active": "Present when active or pressed", @@ -181,7 +183,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"indeterminate\" | \"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Indicator": { "data-active": "Present when active or pressed", @@ -191,7 +194,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"indeterminate\" | \"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" } }, "clipboard": { @@ -233,7 +237,8 @@ "data-part": "content", "data-collapsible": "", "data-state": "\"open\" | \"closed\"", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-has-collapsed-size": "Present when the content has collapsed width or height" }, "Trigger": { "data-scope": "collapsible", @@ -262,6 +267,7 @@ "data-disabled": "Present when disabled", "data-readonly": "Present when read-only", "data-invalid": "Present when invalid", + "data-required": "Present when required", "data-focus": "Present when focused" }, "Control": { @@ -389,6 +395,7 @@ "data-readonly": "Present when read-only", "data-disabled": "Present when disabled", "data-invalid": "Present when invalid", + "data-required": "Present when required", "data-focus": "Present when focused" }, "Control": { @@ -557,6 +564,7 @@ "data-disabled": "Present when disabled", "data-focus": "Present when focused", "data-in-range": "Present when is within the range", + "data-outside-range": "Present when is outside the range", "data-view": "The view of the monthtablecelltrigger", "data-value": "The value of the item" }, @@ -573,6 +581,7 @@ "data-focus": "Present when focused", "data-in-range": "Present when is within the range", "data-disabled": "Present when disabled", + "data-outside-range": "Present when is outside the range", "data-value": "The value of the item", "data-view": "The view of the yeartablecelltrigger" }, @@ -645,7 +654,8 @@ "data-scope": "editable", "data-part": "label", "data-focus": "Present when focused", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Input": { "data-scope": "editable", @@ -688,42 +698,50 @@ "ItemGroup": { "data-scope": "file-upload", "data-part": "item-group", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "Item": { "data-scope": "file-upload", "data-part": "item", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "ItemName": { "data-scope": "file-upload", "data-part": "item-name", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "ItemSizeText": { "data-scope": "file-upload", "data-part": "item-size-text", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "ItemPreview": { "data-scope": "file-upload", "data-part": "item-preview", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "ItemPreviewImage": { "data-scope": "file-upload", "data-part": "item-preview-image", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "ItemDeleteTrigger": { "data-scope": "file-upload", "data-part": "item-delete-trigger", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-type": "The type of the item" }, "Label": { "data-scope": "file-upload", "data-part": "label", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-required": "Present when required" }, "ClearTrigger": { "data-scope": "file-upload", @@ -804,6 +822,43 @@ "data-placement": "The placement of the content" } }, + "image-cropper": { + "Root": { + "data-scope": "image-cropper", + "data-part": "root", + "data-fixed": "", + "data-shape": "", + "data-pinch": "", + "data-dragging": "Present when in the dragging state", + "data-panning": "" + }, + "Viewport": { + "data-scope": "image-cropper", + "data-part": "viewport", + "data-disabled": "Present when disabled" + }, + "Image": { + "data-scope": "image-cropper", + "data-part": "image", + "data-ready": "", + "data-zoom": "", + "data-rotation": "", + "data-flip-horizontal": "", + "data-flip-vertical": "" + }, + "Selection": { + "data-scope": "image-cropper", + "data-part": "selection", + "data-disabled": "Present when disabled", + "data-shape": "" + }, + "Handle": { + "data-scope": "image-cropper", + "data-part": "handle", + "data-position": "", + "data-disabled": "Present when disabled" + } + }, "listbox": { "Root": { "data-scope": "listbox", @@ -865,6 +920,36 @@ "data-empty": "Present when the content is empty" } }, + "marquee": { + "Root": { + "data-scope": "marquee", + "data-part": "root", + "data-state": "\"paused\" | \"idle\"", + "data-orientation": "The orientation of the marquee", + "data-paused": "Present when paused" + }, + "Viewport": { + "data-scope": "marquee", + "data-part": "", + "data-orientation": "The orientation of the viewport", + "data-side": "" + }, + "Content": { + "data-scope": "marquee", + "data-part": "", + "data-index": "The index of the item", + "data-orientation": "The orientation of the content", + "data-side": "", + "data-reverse": "", + "data-clone": "" + }, + "Edge": { + "data-scope": "marquee", + "data-part": "", + "data-side": "", + "data-orientation": "The orientation of the edge" + } + }, "menu": { "ContextTrigger": { "data-scope": "menu", @@ -1010,6 +1095,7 @@ "data-disabled": "Present when disabled", "data-focus": "Present when focused", "data-invalid": "Present when invalid", + "data-required": "Present when required", "data-scrubbing": "" }, "Control": { @@ -1085,7 +1171,8 @@ "data-part": "label", "data-disabled": "Present when disabled", "data-invalid": "Present when invalid", - "data-readonly": "Present when read-only" + "data-readonly": "Present when read-only", + "data-required": "Present when required" }, "Input": { "data-scope": "password-input", @@ -1133,6 +1220,7 @@ "data-invalid": "Present when invalid", "data-disabled": "Present when disabled", "data-complete": "Present when the label value is complete", + "data-required": "Present when required", "data-readonly": "Present when read-only" }, "Input": { @@ -1233,7 +1321,8 @@ "Label": { "data-scope": "rating-group", "data-part": "label", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-required": "Present when required" }, "Control": { "data-scope": "rating-group", @@ -1312,7 +1401,8 @@ "data-part": "label", "data-disabled": "Present when disabled", "data-invalid": "Present when invalid", - "data-readonly": "Present when read-only" + "data-readonly": "Present when read-only", + "data-required": "Present when required" }, "Control": { "data-scope": "select", @@ -1391,7 +1481,8 @@ "Label": { "data-scope": "signature-pad", "data-part": "label", - "data-disabled": "Present when disabled" + "data-disabled": "Present when disabled", + "data-required": "Present when required" }, "Root": { "data-scope": "signature-pad", @@ -1576,7 +1667,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Label": { "data-active": "Present when active or pressed", @@ -1586,7 +1678,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Thumb": { "data-active": "Present when active or pressed", @@ -1596,7 +1689,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" }, "Control": { "data-active": "Present when active or pressed", @@ -1606,7 +1700,8 @@ "data-hover": "Present when hovered", "data-disabled": "Present when disabled", "data-state": "\"checked\" | \"unchecked\"", - "data-invalid": "Present when invalid" + "data-invalid": "Present when invalid", + "data-required": "Present when required" } }, "tabs": { @@ -1659,7 +1754,8 @@ "data-part": "label", "data-disabled": "Present when disabled", "data-invalid": "Present when invalid", - "data-readonly": "Present when read-only" + "data-readonly": "Present when read-only", + "data-required": "Present when required" }, "Control": { "data-scope": "tags-input", @@ -1673,7 +1769,8 @@ "data-scope": "tags-input", "data-part": "input", "data-invalid": "Present when invalid", - "data-readonly": "Present when read-only" + "data-readonly": "Present when read-only", + "data-empty": "Present when the content is empty" }, "Item": { "data-scope": "tags-input", @@ -1845,6 +1942,7 @@ "data-focus": "Present when focused", "data-selected": "Present when selected", "data-disabled": "Present when disabled", + "data-renaming": "", "data-depth": "The depth of the item" }, "ItemText": { @@ -1898,6 +1996,7 @@ "data-disabled": "Present when disabled", "data-selected": "Present when selected", "data-focus": "Present when focused", + "data-renaming": "", "data-value": "The value of the item", "data-depth": "The depth of the item", "data-loading": "Present when loading" @@ -1929,4 +2028,4 @@ "data-disabled": "Present when disabled" } } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16c45636d0..7c14328ffc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3749,6 +3749,9 @@ importers: '@zag-js/i18n-utils': specifier: workspace:* version: link:../packages/utilities/i18n-utils + '@zag-js/image-cropper': + specifier: workspace:* + version: link:../packages/machines/image-cropper '@zag-js/listbox': specifier: workspace:* version: link:../packages/machines/listbox @@ -3794,6 +3797,9 @@ importers: '@zag-js/select': specifier: workspace:* version: link:../packages/machines/select + '@zag-js/shared': + specifier: workspace:* + version: link:../shared '@zag-js/signature-pad': specifier: workspace:* version: link:../packages/machines/signature-pad @@ -16563,7 +16569,7 @@ snapshots: '@typescript-eslint/parser': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.37.0(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.37.0(jiti@2.6.1)) @@ -16602,7 +16608,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -16617,14 +16623,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.37.0(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -16651,7 +16657,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.37.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)))(eslint@9.37.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.1(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.37.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 diff --git a/website/data/components/image-cropper.mdx b/website/data/components/image-cropper.mdx new file mode 100644 index 0000000000..e50942b59b --- /dev/null +++ b/website/data/components/image-cropper.mdx @@ -0,0 +1,227 @@ +--- +title: Image Cropper +description: Using the Image Cropper machine in your project. +package: "@zag-js/image-cropper" +--- + +# Image Cropper + +The image cropper machine keeps track of the crop rectangle, zoom, rotation, pan +offset, flip state, and every gesture required to edit them. It exposes a set of +DOM props so you can render your own viewport, frame, and handles in any +framework. + + + + + +**Features** + +- Pointer, wheel, and pinch gestures that pan, zoom, rotate, and flip the image +- Handles that resize the crop area with snapping, aspect-ratio locking, and + keyboard nudges +- Supports rectangular or circular crops, fixed crop windows, and constrained + min/max dimensions +- Fully controllable zoom/rotation/flip values with change callbacks +- Programmatic helpers such as `api.resize` and `api.getCroppedImage` +- Accessible slider semantics, custom translations, and data attributes for + styling + +## Installation + +To use the image cropper machine in your project, run the following command in +your command line: + + + +## Anatomy + +To set up the image cropper correctly, you'll need to understand its anatomy and +how we name its parts. + +> Each part includes a `data-part` attribute to help identify them in the DOM. + + + +## Usage + +First, import the image cropper package into your project: + +```jsx +import * as imageCropper from "@zag-js/image-cropper" +``` + +The package exports two key functions: + +- `machine` — The state machine logic for the cropper. +- `connect` — The function that translates the machine's state to JSX attributes + and event handlers. + +Next, import the required hooks and functions for your framework and use the +tour machine in your project 🔥 + + + +### Locking the crop area + +Use the following props to constrain the crop window: + +```tsx +const service = useMachine(imageCropper.machine, { + minWidth: 200, + minHeight: 200, + maxWidth: 400, + maxHeight: 400, + aspectRatio: 1, // keep the crop square + cropShape: "circle", + fixedCropArea: true, +}) +``` + +- `aspectRatio` forces width/height to stay in sync. It is also applied while + dragging handles or via keyboard nudges. +- `cropShape` can be `"rectangle"` or `"circle"`. When set to `circle`, the + selection exposes `data-shape="circle"` so you can add a circular mask. +- `fixedCropArea` prevents dragging or resizing the selection. Users can still + pan the image beneath the crop via the viewport. + +### Controlling zoom, rotation, and flip + +`zoom`, `rotation`, and `flip` are fully controllable. Provide the value and the +corresponding `on*Change` callback to keep external state in sync: + +```tsx +const service = useMachine(imageCropper.machine, { + id: useId(), + zoom, + onZoomChange: ({ zoom }) => setZoom(zoom), + rotation, + onRotationChange: ({ rotation }) => setRotation(rotation), + flip, + onFlipChange: ({ flip }) => setFlip(flip), +}) +``` + +When you need to update these values from UI controls, call the helper methods +returned by `connect`: + +- `api.setZoom(value)` clamps to `minZoom`/`maxZoom` and keeps the cursor or + crop center anchored when zooming with the wheel. +- `api.setRotation(value)` clamps to the `[0, 360]` range. +- `api.setFlip(partial)` along with `api.flipHorizontally()` and + `api.flipVertically()` toggles each axis. + +`zoomStep`, `zoomSensitivity`, `minZoom`, and `maxZoom` let you tune how fast +the wheel/pinch gestures respond. The machine also exposes keyboard zoom +shortcuts: `+`/`=` to zoom in and `-`/`_` to zoom out. + +### Programmatic resizing and keyboard nudges + +Every handle can be controlled programmatically with +`api.resize(handlePosition, delta)`, so you can build presets like “16/9” or +buttons that grow/shrink the crop by fixed increments. Keyboard users can move +or resize the selection while the slider-focused selection element is focused: + +- Arrow keys move the crop by `nudgeStep` pixels. Hold **Shift** to use + `nudgeStepShift` and **Ctrl/Cmd** for `nudgeStepCtrl`. +- **Alt + Arrow** resizes the crop along the corresponding axis. + +Listen to `onCropChange` to persist the latest rectangle, e.g. to submit it with +an avatar form. + +### Exporting the cropped result + +Call `api.getCroppedImage({ type, quality, output })` to render the current crop +to a canvas and retrieve either a `Blob` or a `dataUrl`: + +```ts +const blob = await api.getCroppedImage({ + type: "image/jpeg", + quality: 0.9, +}) +``` + +- `type` defaults to `image/png`. +- `quality` is used for lossy formats. +- `output` can be set to `"dataUrl"` when you need an inline preview. + +## Styling guide + +Earlier, we mentioned that each image cropper part has a `data-part` attribute +added to them to select and style them in the DOM. + +```css +[data-scope="image-cropper"][data-part="root"] { + /* styles for the root part */ +} + +[data-scope="image-cropper"][data-part="viewport"] { + /* styles for the viewport part */ +} + +[data-scope="image-cropper"][data-part="image"] { + /* styles for the image part */ +} + +[data-scope="image-cropper"][data-part="selection"] { + /* styles for the selection part */ +} + +[data-scope="image-cropper"][data-part="handle"] { + /* styles for the handle part */ +} +``` + +### Selection shapes + +The selection can be styled based on its shape: + +```css +[data-part="selection"][data-shape="circle"] { + /* styles for circular selection */ +} + +[data-part="selection"][data-shape="rectangle"] { + /* styles for rectangular selection */ +} +``` + +### States + +Various states can be styled using data attributes: + +```css +[data-part="root"][data-dragging] { + /* styles when dragging the selection */ +} + +[data-part="root"][data-fixed] { + /* styles when the crop area is fixed */ +} +``` + +## Keyboard Interactions + + + +## Methods and Properties + +### Machine Context + +The image cropper machine exposes the following context properties: + + + +### Machine API + +The image cropper `api` exposes the following methods: + + + +### Data Attributes + + + +### CSS Variables + + diff --git a/website/data/snippets/react/image-cropper/installation.mdx b/website/data/snippets/react/image-cropper/installation.mdx new file mode 100644 index 0000000000..8a50f6eaf9 --- /dev/null +++ b/website/data/snippets/react/image-cropper/installation.mdx @@ -0,0 +1,5 @@ +```bash +npm install @zag-js/image-cropper @zag-js/react +# or +yarn add @zag-js/image-cropper @zag-js/react +``` diff --git a/website/data/snippets/react/image-cropper/usage.mdx b/website/data/snippets/react/image-cropper/usage.mdx new file mode 100644 index 0000000000..2d4f1d7bf8 --- /dev/null +++ b/website/data/snippets/react/image-cropper/usage.mdx @@ -0,0 +1,44 @@ +```jsx +import * as imageCropper from "@zag-js/image-cropper" +import { normalizeProps, useMachine } from "@zag-js/react" +import { useId } from "react" + +const handles: imageCropper.HandlePosition[] = [ + "top-left", + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left", +] + +export function ImageCropper() { + const service = useMachine(imageCropper.machine, { + id: useId(), + }) + + const api = imageCropper.connect(service, normalizeProps) + + return ( +
+
+ + +
+ {handles.map((position) => ( +
+ +
+ ))} +
+
+
+ ) +} +``` diff --git a/website/data/snippets/solid/image-cropper/installation.mdx b/website/data/snippets/solid/image-cropper/installation.mdx new file mode 100644 index 0000000000..3260eeba8a --- /dev/null +++ b/website/data/snippets/solid/image-cropper/installation.mdx @@ -0,0 +1,5 @@ +```bash +npm install @zag-js/image-cropper @zag-js/solid +# or +yarn add @zag-js/image-cropper @zag-js/solid +``` diff --git a/website/data/snippets/solid/image-cropper/usage.mdx b/website/data/snippets/solid/image-cropper/usage.mdx new file mode 100644 index 0000000000..3e331fca0f --- /dev/null +++ b/website/data/snippets/solid/image-cropper/usage.mdx @@ -0,0 +1,46 @@ +```jsx +import * as imageCropper from "@zag-js/image-cropper" +import { normalizeProps, useMachine } from "@zag-js/solid" +import { For, createMemo, createUniqueId } from "solid-js" + +const handles: imageCropper.HandlePosition[] = [ + "top-left", + "top", + "top-right", + "right", + "bottom-right", + "bottom", + "bottom-left", + "left", +] + +export function ImageCropper() { + const service = useMachine(imageCropper.machine, { + id: createUniqueId(), + }) + + const api = createMemo(() => imageCropper.connect(service, normalizeProps)) + + return ( +
+
+ + +
+ + {(position) => ( +
+ +
+ )} +
+
+
+
+ ) +} +``` diff --git a/website/data/snippets/svelte/image-cropper/installation.mdx b/website/data/snippets/svelte/image-cropper/installation.mdx new file mode 100644 index 0000000000..898b1d9c92 --- /dev/null +++ b/website/data/snippets/svelte/image-cropper/installation.mdx @@ -0,0 +1,5 @@ +```bash +npm install @zag-js/image-cropper @zag-js/svelte +# or +yarn add @zag-js/image-cropper @zag-js/svelte +``` diff --git a/website/data/snippets/svelte/image-cropper/usage.mdx b/website/data/snippets/svelte/image-cropper/usage.mdx new file mode 100644 index 0000000000..a8aeb34b23 --- /dev/null +++ b/website/data/snippets/svelte/image-cropper/usage.mdx @@ -0,0 +1,41 @@ +```svelte + + +
+
+ + +
+ {#each handles as position} +
+ +
+ {/each} +
+
+
+``` diff --git a/website/data/snippets/vue/image-cropper/installation.mdx b/website/data/snippets/vue/image-cropper/installation.mdx new file mode 100644 index 0000000000..379ddf8463 --- /dev/null +++ b/website/data/snippets/vue/image-cropper/installation.mdx @@ -0,0 +1,5 @@ +```bash +npm install @zag-js/image-cropper @zag-js/vue +# or +yarn add @zag-js/image-cropper @zag-js/vue +``` diff --git a/website/data/snippets/vue/image-cropper/usage.mdx b/website/data/snippets/vue/image-cropper/usage.mdx new file mode 100644 index 0000000000..f7860cb392 --- /dev/null +++ b/website/data/snippets/vue/image-cropper/usage.mdx @@ -0,0 +1,46 @@ +```html + + + +``` diff --git a/website/demos/image-cropper.tsx b/website/demos/image-cropper.tsx new file mode 100644 index 0000000000..09aeb66ce9 --- /dev/null +++ b/website/demos/image-cropper.tsx @@ -0,0 +1,34 @@ +import * as imageCropper from "@zag-js/image-cropper" +import { normalizeProps, useMachine } from "@zag-js/react" +import { handlePositions } from "@zag-js/shared" +import { useEffect, useId, useState } from "react" + +interface ImageCropperProps extends Omit {} + +export function ImageCropper(props: ImageCropperProps) { + const service = useMachine(imageCropper.machine, { + id: useId(), + ...props, + }) + + const api = imageCropper.connect(service, normalizeProps) + + return ( +
+
+ +
+ {handlePositions.map((position) => ( +
+
+
+ ))} +
+
+
+ ) +} diff --git a/website/demos/index.tsx b/website/demos/index.tsx index 68ec2a5250..b8cc89a4ee 100644 --- a/website/demos/index.tsx +++ b/website/demos/index.tsx @@ -13,6 +13,7 @@ import { Dialog } from "./dialog" import { Editable } from "./editable" import { FileUpload } from "./file-upload" import { HoverCard } from "./hover-card" +import { ImageCropper } from "./image-cropper" import { Menu } from "./menu" import { NestedMenu } from "./nested-menu" import { NumberInput } from "./number-input" @@ -208,6 +209,13 @@ const components = { }} /> ), + ImageCropper: () => ( + + ), Menu: () => , ContextMenu: () => , NestedMenu: () => , diff --git a/website/package.json b/website/package.json index 1dd9492925..d7004b6c1b 100644 --- a/website/package.json +++ b/website/package.json @@ -37,6 +37,7 @@ "@zag-js/floating-panel": "workspace:*", "@zag-js/hover-card": "workspace:*", "@zag-js/i18n-utils": "workspace:*", + "@zag-js/image-cropper": "workspace:*", "@zag-js/listbox": "workspace:*", "@zag-js/menu": "workspace:*", "@zag-js/number-input": "workspace:*", @@ -52,6 +53,7 @@ "@zag-js/react": "workspace:*", "@zag-js/scroll-area": "workspace:*", "@zag-js/select": "workspace:*", + "@zag-js/shared": "workspace:*", "@zag-js/signature-pad": "workspace:*", "@zag-js/slider": "workspace:*", "@zag-js/splitter": "workspace:*", diff --git a/website/sidebar.config.ts b/website/sidebar.config.ts index 80d690969e..1a8f669104 100644 --- a/website/sidebar.config.ts +++ b/website/sidebar.config.ts @@ -90,6 +90,7 @@ const sidebar: Record<"docs", SidebarItem[]> = { { type: "doc", label: "File Upload", id: "file-upload" }, { type: "doc", label: "Floating Panel", id: "floating-panel" }, { type: "doc", label: "Hover Card", id: "hover-card" }, + // { type: "doc", label: "Image Cropper", id: "image-cropper", beta: true }, { type: "doc", label: "Listbox", id: "listbox" }, { type: "doc", label: "Menu", id: "menu" }, { type: "doc", label: "Context Menu", id: "context-menu" }, diff --git a/website/styles/machines/image-cropper.css b/website/styles/machines/image-cropper.css new file mode 100644 index 0000000000..c5eb058005 --- /dev/null +++ b/website/styles/machines/image-cropper.css @@ -0,0 +1,98 @@ +[data-scope="image-cropper"][data-part="viewport"] { + position: relative; + overflow: hidden; + touch-action: none; + user-select: none; +} + +[data-scope="image-cropper"][data-part="selection"] { + position: absolute; + box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.5); + cursor: move; + touch-action: none; +} + +[data-scope="image-cropper"][data-part="selection"][data-shape="circle"] { + border-radius: 50%; +} + +[data-scope="image-cropper"][data-part="image"] { + display: block; + pointer-events: none; + user-select: none; +} + +[data-scope="image-cropper"][data-part="handle"] { + position: absolute; + width: 30px; + height: 30px; + touch-action: none; + cursor: grab; +} + +[data-scope="image-cropper"][data-part="handle"] > div { + width: 10px; + height: 10px; + background: #fff; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +[data-scope="image-cropper"][data-part="handle"][data-position="top-left"] { + top: 0; + left: 0; + transform: translate(-50%, -50%); + cursor: nwse-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="top"] { + top: 0; + left: 50%; + transform: translate(-50%, -50%); + cursor: ns-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="top-right"] { + top: 0; + right: 0; + transform: translate(50%, -50%); + cursor: nesw-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="right"] { + top: 50%; + right: 0; + transform: translate(50%, -50%); + cursor: ew-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="bottom-right"] { + bottom: 0; + right: 0; + transform: translate(50%, 50%); + cursor: nwse-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="bottom"] { + bottom: 0; + left: 50%; + transform: translate(-50%, 50%); + cursor: ns-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="bottom-left"] { + bottom: 0; + left: 0; + transform: translate(-50%, 50%); + cursor: nesw-resize; +} + +[data-scope="image-cropper"][data-part="handle"][data-position="left"] { + top: 50%; + left: 0; + transform: translate(-50%, -50%); + cursor: ew-resize; +} diff --git a/website/styles/machines/index.css b/website/styles/machines/index.css index b65db4543c..d3699ef50d 100644 --- a/website/styles/machines/index.css +++ b/website/styles/machines/index.css @@ -13,6 +13,7 @@ @import url(./editable.css); @import url(./file-upload.css); @import url(./hover-card.css); +@import url(./image-cropper.css); @import url(./listbox.css); @import url(./menu.css); @import url(./number-input.css); From d312cf08d0de9ef6a5b0efc1e35f1b9954434f24 Mon Sep 17 00:00:00 2001 From: Nelson Lai Date: Wed, 29 Oct 2025 01:00:19 +0800 Subject: [PATCH 2/2] docs: better docs --- website/data/components/image-cropper.mdx | 163 ++++++++++++++-------- 1 file changed, 107 insertions(+), 56 deletions(-) diff --git a/website/data/components/image-cropper.mdx b/website/data/components/image-cropper.mdx index e50942b59b..2617268add 100644 --- a/website/data/components/image-cropper.mdx +++ b/website/data/components/image-cropper.mdx @@ -58,92 +58,143 @@ The package exports two key functions: and event handlers. Next, import the required hooks and functions for your framework and use the -tour machine in your project 🔥 +image cropper machine in your project 🔥 -### Locking the crop area +### Setting the initial crop -Use the following props to constrain the crop window: +Pass an `initialCrop` to start from a specific rectangle. The size is +constrained to your min/max and viewport, and the position is clamped within the +viewport. -```tsx +```jsx {2-6} +const service = useMachine(imageCropper.machine, { + initialCrop: { x: 40, y: 40, width: 240, height: 240 }, + aspectRatio: 1, // optional, lock to square +}) +const api = imageCropper.connect(service, normalizeProps) +``` + +### Fixed crop area + +Lock the crop window and allow only panning/zooming of the image beneath it by +setting `fixedCropArea: true`. + +```jsx {2} const service = useMachine(imageCropper.machine, { - minWidth: 200, - minHeight: 200, - maxWidth: 400, - maxHeight: 400, - aspectRatio: 1, // keep the crop square - cropShape: "circle", fixedCropArea: true, }) ``` -- `aspectRatio` forces width/height to stay in sync. It is also applied while - dragging handles or via keyboard nudges. -- `cropShape` can be `"rectangle"` or `"circle"`. When set to `circle`, the - selection exposes `data-shape="circle"` so you can add a circular mask. -- `fixedCropArea` prevents dragging or resizing the selection. Users can still - pan the image beneath the crop via the viewport. +### Crop shape and aspect ratio + +- `cropShape` can be `"rectangle"` or `"circle"`. +- `aspectRatio` can lock the crop to a width/height ratio. When `aspectRatio` is + not set and `cropShape` is `"rectangle"`, holding Shift while resizing locks + to the current ratio. + +```jsx {2-3} +const service = useMachine(imageCropper.machine, { + cropShape: "circle", + aspectRatio: 1, // ignored for circle +}) +``` ### Controlling zoom, rotation, and flip -`zoom`, `rotation`, and `flip` are fully controllable. Provide the value and the -corresponding `on*Change` callback to keep external state in sync: +You can configure defaults and limits, and also control them programmatically +using the API. -```tsx +```jsx {2-6} const service = useMachine(imageCropper.machine, { - id: useId(), - zoom, - onZoomChange: ({ zoom }) => setZoom(zoom), - rotation, - onRotationChange: ({ rotation }) => setRotation(rotation), - flip, - onFlipChange: ({ flip }) => setFlip(flip), + defaultZoom: 1.25, + minZoom: 1, + maxZoom: 5, + defaultRotation: 0, + defaultFlip: { horizontal: false, vertical: false }, }) +const api = imageCropper.connect(service, normalizeProps) + +// Programmatic controls +api.setZoom(2) // zoom to 2x +api.setRotation(90) // rotate to 90 degrees +api.flipHorizontally() // toggle horizontal flip +api.setFlip({ vertical: true }) // set vertical flip on ``` -When you need to update these values from UI controls, call the helper methods -returned by `connect`: +### Programmatic resizing -- `api.setZoom(value)` clamps to `minZoom`/`maxZoom` and keeps the cursor or - crop center anchored when zooming with the wheel. -- `api.setRotation(value)` clamps to the `[0, 360]` range. -- `api.setFlip(partial)` along with `api.flipHorizontally()` and - `api.flipVertically()` toggles each axis. +Use `api.resize(handle, delta)` to resize from any handle programmatically. +Positive `delta` grows outward, negative shrinks inward. -`zoomStep`, `zoomSensitivity`, `minZoom`, and `maxZoom` let you tune how fast -the wheel/pinch gestures respond. The machine also exposes keyboard zoom -shortcuts: `+`/`=` to zoom in and `-`/`_` to zoom out. +```jsx +// Grow the selection by 8px from the right edge +api.resize("right", 8) +// Shrink from top-left corner by 4px in both axes +api.resize("top-left", -4) +``` -### Programmatic resizing and keyboard nudges +### Getting the cropped image -Every handle can be controlled programmatically with -`api.resize(handlePosition, delta)`, so you can build presets like “16/9” or -buttons that grow/shrink the crop by fixed increments. Keyboard users can move -or resize the selection while the slider-focused selection element is focused: +Use `api.getCroppedImage` to export the current crop, taking +zoom/rotation/flip/pan into account. -- Arrow keys move the crop by `nudgeStep` pixels. Hold **Shift** to use - `nudgeStepShift` and **Ctrl/Cmd** for `nudgeStepCtrl`. -- **Alt + Arrow** resizes the crop along the corresponding axis. +```jsx +// Blob (default) +const blob = await api.getCroppedImage({ type: "image/png", quality: 0.92 }) + +// Data URL +const dataUrl = await api.getCroppedImage({ + output: "dataUrl", + type: "image/jpeg", + quality: 0.85, +}) -Listen to `onCropChange` to persist the latest rectangle, e.g. to submit it with -an avatar form. +// Example usage +if (blob) { + const url = URL.createObjectURL(blob) + previewImg.src = url +} +``` -### Exporting the cropped result +### Touch and wheel gestures -Call `api.getCroppedImage({ type, quality, output })` to render the current crop -to a canvas and retrieve either a `Blob` or a `dataUrl`: +- Use the mouse wheel over the viewport to zoom at the pointer location. +- Pinch with two fingers to zoom and pan; the machine smooths tiny changes and + tracks the pinch midpoint. +- Drag on the viewport background to pan the image (when not dragging the + selection). -```ts -const blob = await api.getCroppedImage({ - type: "image/jpeg", - quality: 0.9, +### Keyboard nudges + +Configure keyboard nudge steps for move/resize: + +```jsx {2-4} +const service = useMachine(imageCropper.machine, { + nudgeStep: 1, + nudgeStepShift: 10, + nudgeStepCtrl: 50, }) ``` -- `type` defaults to `image/png`. -- `quality` is used for lossy formats. -- `output` can be set to `"dataUrl"` when you need an inline preview. +### Accessibility + +- The root is a live region with helpful descriptions of crop, zoom, and + rotation status. +- The selection exposes slider-like semantics to assistive tech and supports + keyboard movement, resizing (Alt+Arrows), and zooming (+/-). +- Customize accessible labels and descriptions via `translations`: + +```jsx {2-7} +const service = useMachine(imageCropper.machine, { + translations: { + rootLabel: "Product image cropper", + selectionInstructions: + "Use arrow keys to move, Alt+arrows to resize, and +/- to zoom.", + }, +}) +``` ## Styling guide