@michaelyagi/kiri 0.1.0-alpha.7 → 0.1.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,11 +9,12 @@ A dependency-free TypeScript library for interactive image cropping in the brows
9
9
  - **Drag, zoom, rotate, flip** — wheel/trackpad pinch zoom, an optional
10
10
  built-in zoom slider (`showZoomer`, placeable on any of the four sides),
11
11
  90° rotation, independent horizontal/vertical flip
12
- - **Rectangle or circle frame** — circle is a real clip on export (transparent
13
- corners on PNG/WebP), not just a visual overlay
12
+ - **Rectangle, circle, or rounded-rectangle frame** — circle/rounded-rectangle
13
+ are real clips on export (transparent corners on PNG/WebP), not just a
14
+ visual overlay
14
15
  - **Zero-CSS sizing** — the stage auto-sizes itself to the frame's dimensions
15
- - **Filters** — brightness/contrast/saturation/grayscale/sepia, applied
16
- identically to the live preview and the export
16
+ - **Filters** — brightness/contrast/saturation/sharpness/grayscale/sepia,
17
+ applied identically to the live preview and the export
17
18
  - **Automatic EXIF orientation correction** on load
18
19
  - **Resizable frame**, four independent corner handles, with an optional
19
20
  `lockAspectRatio` to keep a fixed ratio while resizing
@@ -78,7 +79,7 @@ an interactive playground, and the full API reference.
78
79
 
79
80
  ## Status
80
81
 
81
- **`0.1.0-alpha.7`** — see [CHANGELOG.md](./CHANGELOG.md)
82
+ **`0.1.0-alpha.9`** — see [CHANGELOG.md](./CHANGELOG.md)
82
83
  for what's in it. Core publishes to npm automatically on version tags via
83
84
  GitHub Actions; `kiri-react`/`kiri-vue` aren't published yet. The public API
84
85
  is expected to be mostly stable but may still change before a `0.1.0`
package/dist/export.d.ts CHANGED
@@ -23,5 +23,5 @@ export declare function computeFrameSourceRect(rendered: Size, offset: Offset, f
23
23
  * carried through untouched in the result for the caller to apply.
24
24
  */
25
25
  export declare function computeCropRegion(natural: Size, frame: Size, state: KiriState): CropRegion;
26
- export declare function renderCropToCanvas(img: HTMLImageElement, state: KiriState, frame: Size, outputWidth: number, outputHeight: number, frameShape: FrameShape): HTMLCanvasElement;
27
- export declare function exportCrop(img: HTMLImageElement, state: KiriState, frame: Size, frameShape: FrameShape, options: ExportOptions): Promise<ExportResult>;
26
+ export declare function renderCropToCanvas(img: HTMLImageElement, state: KiriState, frame: Size, outputWidth: number, outputHeight: number, frameShape: FrameShape, cornerRadius: number, sharpenFilterId: string): HTMLCanvasElement;
27
+ export declare function exportCrop(img: HTMLImageElement, state: KiriState, frame: Size, frameShape: FrameShape, cornerRadius: number, sharpenFilterId: string, options: ExportOptions): Promise<ExportResult>;
package/dist/filters.d.ts CHANGED
@@ -2,10 +2,44 @@ import { Filters } from './types';
2
2
  export declare const DEFAULT_FILTERS: Filters;
3
3
  /** Merges a partial filter update into the current filters, clamping numeric values to >= 0. */
4
4
  export declare function mergeFilters(current: Filters, partial: Partial<Filters>): Filters;
5
+ /**
6
+ * The 3x3 unsharp-mask kernel for `sharpness`, as a `feConvolveMatrix`
7
+ * `kernelMatrix` attribute value (row-major, space-separated):
8
+ *
9
+ * ```
10
+ * 0 -k 0
11
+ * -k 1+4k -k
12
+ * 0 -k 0
13
+ * ```
14
+ *
15
+ * The matrix always sums to `1`, so the convolution needs no separate
16
+ * `divisor`. `sharpness <= 1` collapses to the identity kernel (center `1`,
17
+ * neighbors `0`) — no blur/sharpen either way, matching the `1` = unchanged
18
+ * convention used by the other numeric filters. `4.5` is chosen so a
19
+ * `sharpness` of `2`–`3` (a reasonable slider range) lands in a visually
20
+ * subtle-to-strong sharpening range, not an immediately harsh one.
21
+ */
22
+ export declare function sharpenKernelMatrix(sharpness: number): string;
5
23
  /**
6
24
  * Builds a CSS `filter` value from the current filter state. Used verbatim as
7
25
  * both the live-preview `img.style.filter` and a canvas 2D context's
8
26
  * `ctx.filter` before drawing, so preview and export always match exactly —
9
- * no separately hand-rolled brightness/contrast/saturation pixel math.
27
+ * no separately hand-rolled brightness/contrast/saturation/sharpness pixel
28
+ * math. Brightness/contrast/saturation/grayscale/sepia are native CSS filter
29
+ * functions; CSS has no `sharpen()`, so `sharpness` instead references an
30
+ * SVG `feConvolveMatrix` filter (by `sharpenFilterId`) that `stage.ts` keeps
31
+ * in sync with the current `sharpness` value — `ctx.filter` and
32
+ * `img.style.filter` both support `url(#id)` references to it identically.
33
+ *
34
+ * The `url(#id)` reference goes **first**, before the native CSS filter
35
+ * functions — not last, despite sharpening-as-a-final-step being the more
36
+ * common photo-editing order. Verified directly in a real browser (Chromium)
37
+ * that a `url(#id)` SVG filter reference placed *after* one or more native
38
+ * CSS filter functions in the same chain silently produces a fully blank
39
+ * (transparent) result, while the identical chain with `url(#id)` first
40
+ * renders correctly. Sharpening a source that hasn't had brightness/contrast/
41
+ * saturation applied yet is a legitimate (if less common) ordering rather
42
+ * than a compromise — but the ordering here is load-bearing either way, not
43
+ * arbitrary; don't reorder this without re-verifying in a real browser.
10
44
  */
11
- export declare function buildFilterString(filters: Filters): string;
45
+ export declare function buildFilterString(filters: Filters, sharpenFilterId: string): string;
package/dist/kiri.css CHANGED
@@ -34,6 +34,9 @@
34
34
  .kiri-frame--circle {
35
35
  border-radius: 50%;
36
36
  }
37
+ /* .kiri-frame--rounded-rectangle's border-radius is a per-instance pixel
38
+ value (frame.cornerRadius), so it's set inline by stage.ts instead of
39
+ here — this class exists as a CSS hook for consumers, not styling. */
37
40
  .kiri-frame-handle {
38
41
  position: absolute;
39
42
  width: 10px;
package/dist/kiri.d.ts CHANGED
@@ -69,9 +69,10 @@ export declare class Kiri {
69
69
  */
70
70
  setFilters(filters: Partial<Filters>): void;
71
71
  /**
72
- * Renders the current crop. A circle frame is a real clip in the output
73
- * (transparent corners on PNG/WebP); a circle exported as JPEG warns and
74
- * renders solid black corners instead, since JPEG has no alpha channel.
72
+ * Renders the current crop. A circle or rounded-rectangle frame is a real
73
+ * clip in the output (transparent corners on PNG/WebP); either shape
74
+ * exported as JPEG warns and renders solid black corners instead, since
75
+ * JPEG has no alpha channel.
75
76
  * @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
76
77
  */
77
78
  export(options?: ExportOptions): Promise<ExportResult>;
package/dist/kiri.js CHANGED
@@ -9,6 +9,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
9
9
  brightness: 1,
10
10
  contrast: 1,
11
11
  saturation: 1,
12
+ sharpness: 1,
12
13
  grayscale: false,
13
14
  sepia: false
14
15
  };
@@ -17,21 +18,52 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
17
18
  brightness: Math.max(0, partial.brightness ?? current.brightness),
18
19
  contrast: Math.max(0, partial.contrast ?? current.contrast),
19
20
  saturation: Math.max(0, partial.saturation ?? current.saturation),
21
+ sharpness: Math.max(0, partial.sharpness ?? current.sharpness),
20
22
  grayscale: partial.grayscale ?? current.grayscale,
21
23
  sepia: partial.sepia ?? current.sepia
22
24
  };
23
25
  }
24
- function buildFilterString(filters) {
25
- const parts = [
26
+ function sharpenKernelMatrix(sharpness) {
27
+ const weight = sharpness > 1 ? (sharpness - 1) / 4.5 : 0;
28
+ const center = 1 + 4 * weight;
29
+ const neighbor = -weight;
30
+ return `0 ${neighbor} 0 ${neighbor} ${center} ${neighbor} 0 ${neighbor} 0`;
31
+ }
32
+ function buildFilterString(filters, sharpenFilterId) {
33
+ const parts = [];
34
+ if (filters.sharpness > 1) parts.push(`url(#${sharpenFilterId})`);
35
+ parts.push(
26
36
  `brightness(${filters.brightness})`,
27
37
  `contrast(${filters.contrast})`,
28
38
  `saturate(${filters.saturation})`
29
- ];
39
+ );
30
40
  if (filters.grayscale) parts.push("grayscale(1)");
31
41
  if (filters.sepia) parts.push("sepia(1)");
32
42
  return parts.join(" ");
33
43
  }
34
- function createStage(container, frameShape, frameWidth, frameHeight, zoomer) {
44
+ const SVG_NS = "http://www.w3.org/2000/svg";
45
+ let sharpenFilterCounter = 0;
46
+ function createSharpenFilter() {
47
+ sharpenFilterCounter += 1;
48
+ const filterId = `kiri-sharpen-${sharpenFilterCounter}`;
49
+ const svg = document.createElementNS(SVG_NS, "svg");
50
+ svg.setAttribute("width", "0");
51
+ svg.setAttribute("height", "0");
52
+ svg.style.position = "absolute";
53
+ const filterEl = document.createElementNS(SVG_NS, "filter");
54
+ filterEl.setAttribute("id", filterId);
55
+ filterEl.setAttribute("color-interpolation-filters", "sRGB");
56
+ const kernelEl = document.createElementNS(SVG_NS, "feConvolveMatrix");
57
+ kernelEl.setAttribute("order", "3");
58
+ kernelEl.setAttribute("divisor", "1");
59
+ kernelEl.setAttribute("edgeMode", "duplicate");
60
+ kernelEl.setAttribute("preserveAlpha", "true");
61
+ kernelEl.setAttribute("kernelMatrix", sharpenKernelMatrix(1));
62
+ filterEl.appendChild(kernelEl);
63
+ svg.appendChild(filterEl);
64
+ return { svg, filterId, kernelEl };
65
+ }
66
+ function createStage(container, frameShape, frameWidth, frameHeight, cornerRadius, zoomer) {
35
67
  container.innerHTML = "";
36
68
  const stageEl = document.createElement("div");
37
69
  stageEl.className = "kiri-stage";
@@ -47,10 +79,15 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
47
79
  imgEl.draggable = false;
48
80
  imageLayerEl.appendChild(imgEl);
49
81
  const frameEl = document.createElement("div");
50
- frameEl.className = frameShape === "circle" ? "kiri-frame kiri-frame--circle" : "kiri-frame";
82
+ frameEl.className = frameShape === "circle" ? "kiri-frame kiri-frame--circle" : frameShape === "rounded-rectangle" ? "kiri-frame kiri-frame--rounded-rectangle" : "kiri-frame";
51
83
  setFrameSize(frameEl, frameWidth, frameHeight);
84
+ if (frameShape === "rounded-rectangle") {
85
+ frameEl.style.borderRadius = `${cornerRadius}px`;
86
+ }
87
+ const { svg: sharpenSvg, filterId: sharpenFilterId, kernelEl: sharpenKernelEl } = createSharpenFilter();
52
88
  stageEl.appendChild(imageLayerEl);
53
89
  stageEl.appendChild(frameEl);
90
+ stageEl.appendChild(sharpenSvg);
54
91
  let zoomerEl = null;
55
92
  if (zoomer.show) {
56
93
  const rootEl = document.createElement("div");
@@ -68,7 +105,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
68
105
  } else {
69
106
  container.appendChild(stageEl);
70
107
  }
71
- return { stageEl, frameEl, imageLayerEl, imgEl, zoomerEl };
108
+ return { stageEl, frameEl, imageLayerEl, imgEl, zoomerEl, sharpenFilterId, sharpenKernelEl };
72
109
  }
73
110
  function setFrameSize(frameEl, width, height) {
74
111
  frameEl.style.width = `${width}px`;
@@ -84,8 +121,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
84
121
  const scaleY = renderedScale * (state.flip.vertical ? -1 : 1);
85
122
  imageLayerEl.style.transform = `translate(-50%, -50%) translate(${state.offset.x}px, ${state.offset.y}px) rotate(${state.rotation}deg) scale(${scaleX}, ${scaleY})`;
86
123
  }
87
- function applyFilters(imgEl, filters) {
88
- imgEl.style.filter = buildFilterString(filters);
124
+ function applyFilters(imgEl, filters, sharpenKernelEl, sharpenFilterId) {
125
+ sharpenKernelEl.setAttribute("kernelMatrix", sharpenKernelMatrix(filters.sharpness));
126
+ imgEl.style.filter = buildFilterString(filters, sharpenFilterId);
89
127
  }
90
128
  function effectiveNaturalSize(natural, rotationDeg) {
91
129
  const swapped = (rotationDeg / 90 % 2 + 2) % 2 === 1;
@@ -368,7 +406,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
368
406
  flip: { ...state.flip }
369
407
  };
370
408
  }
371
- function renderCropToCanvas(img, state, frame, outputWidth, outputHeight, frameShape) {
409
+ function renderCropToCanvas(img, state, frame, outputWidth, outputHeight, frameShape, cornerRadius, sharpenFilterId) {
372
410
  const natural = { width: img.naturalWidth, height: img.naturalHeight };
373
411
  const scale = computeCoverScale(natural, frame, state.rotation) * state.zoom;
374
412
  const rendered = effectiveRenderedSize(natural, frame, state.rotation, state.zoom);
@@ -380,7 +418,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
380
418
  sctx.translate(sourceCanvas.width / 2, sourceCanvas.height / 2);
381
419
  sctx.rotate(state.rotation * Math.PI / 180);
382
420
  sctx.scale(scale * (state.flip.horizontal ? -1 : 1), scale * (state.flip.vertical ? -1 : 1));
383
- sctx.filter = buildFilterString(state.filters);
421
+ sctx.filter = buildFilterString(state.filters, sharpenFilterId);
384
422
  sctx.drawImage(img, -natural.width / 2, -natural.height / 2, natural.width, natural.height);
385
423
  const { left: frameLeft, top: frameTop } = computeFrameSourceRect(
386
424
  rendered,
@@ -405,6 +443,13 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
405
443
  Math.PI * 2
406
444
  );
407
445
  octx.clip();
446
+ } else if (frameShape === "rounded-rectangle") {
447
+ const scale2 = outputWidth / frame.width;
448
+ const radius = clampNum(cornerRadius * scale2, 0, Math.min(outputWidth, outputHeight) / 2);
449
+ octx.save();
450
+ octx.beginPath();
451
+ octx.roundRect(0, 0, outputWidth, outputHeight, radius);
452
+ octx.clip();
408
453
  }
409
454
  octx.drawImage(
410
455
  sourceCanvas,
@@ -417,21 +462,30 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
417
462
  outputWidth,
418
463
  outputHeight
419
464
  );
420
- if (frameShape === "circle") octx.restore();
465
+ if (frameShape === "circle" || frameShape === "rounded-rectangle") octx.restore();
421
466
  return outCanvas;
422
467
  }
423
- async function exportCrop(img, state, frame, frameShape, options) {
468
+ async function exportCrop(img, state, frame, frameShape, cornerRadius, sharpenFilterId, options) {
424
469
  const type = resolveEnumOption(options.type, VALID_EXPORT_TYPES, "base64", "export type");
425
470
  const format = resolveEnumOption(options.format, VALID_EXPORT_FORMATS, "image/png", "export format");
426
471
  const quality = options.quality;
427
472
  const width = options.width ?? frame.width;
428
473
  const height = options.height ?? frame.height;
429
- if (frameShape === "circle" && format === "image/jpeg") {
474
+ if ((frameShape === "circle" || frameShape === "rounded-rectangle") && format === "image/jpeg") {
430
475
  console.warn(
431
- "Kiri: exporting a circle-shaped frame as image/jpeg — JPEG has no alpha channel, so the area outside the circle will render as solid black instead of transparent. Use image/png or image/webp instead."
476
+ `Kiri: exporting a ${frameShape}-shaped frame as image/jpeg — JPEG has no alpha channel, so the area outside the shape will render as solid black instead of transparent. Use image/png or image/webp instead.`
432
477
  );
433
478
  }
434
- const canvas = renderCropToCanvas(img, state, frame, width, height, frameShape);
479
+ const canvas = renderCropToCanvas(
480
+ img,
481
+ state,
482
+ frame,
483
+ width,
484
+ height,
485
+ frameShape,
486
+ cornerRadius,
487
+ sharpenFilterId
488
+ );
435
489
  if (type === "canvas") return canvas;
436
490
  if (type === "base64") return canvas.toDataURL(format, quality);
437
491
  return new Promise((resolve, reject) => {
@@ -468,7 +522,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
468
522
  }
469
523
  const DEFAULT_FRAME_SIZE = 200;
470
524
  const MIN_FRAME_SIZE = 20;
471
- const VALID_FRAME_SHAPES = ["rectangle", "circle"];
525
+ const DEFAULT_CORNER_RADIUS = 20;
526
+ const VALID_FRAME_SHAPES = ["rectangle", "circle", "rounded-rectangle"];
472
527
  const VALID_ZOOMER_POSITIONS = ["top", "bottom", "left", "right"];
473
528
  function resolveMouseWheelZoom(value) {
474
529
  if (value === void 0) return true;
@@ -502,7 +557,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
502
557
  /** Snapshot taken right after `load()` resolves, so `reset()` has something to revert to. */
503
558
  __publicField(this, "initialState", null);
504
559
  __publicField(this, "listeners", { change: [] });
505
- var _a, _b, _c;
560
+ var _a, _b, _c, _d;
506
561
  if (!container || typeof container.appendChild !== "function") {
507
562
  throw new Error(
508
563
  "Kiri: container element is null/undefined or not a DOM element. This usually means the element wasn't in the DOM yet when `new Kiri(...)` ran — e.g. document.getElementById() was called before the element existed. Place the <script> after the element, or construct inside a DOMContentLoaded listener."
@@ -518,7 +573,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
518
573
  "frame.shape"
519
574
  ),
520
575
  width: ((_b = options.frame) == null ? void 0 : _b.width) ?? DEFAULT_FRAME_SIZE,
521
- height: ((_c = options.frame) == null ? void 0 : _c.height) ?? DEFAULT_FRAME_SIZE
576
+ height: ((_c = options.frame) == null ? void 0 : _c.height) ?? DEFAULT_FRAME_SIZE,
577
+ cornerRadius: ((_d = options.frame) == null ? void 0 : _d.cornerRadius) ?? DEFAULT_CORNER_RADIUS
522
578
  },
523
579
  minZoom: options.minZoom ?? 1,
524
580
  maxZoom: options.maxZoom ?? 4,
@@ -544,6 +600,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
544
600
  this.opts.frame.shape,
545
601
  this.opts.frame.width,
546
602
  this.opts.frame.height,
603
+ this.opts.frame.cornerRadius,
547
604
  {
548
605
  show: this.opts.showZoomer,
549
606
  position: this.opts.zoomerPosition,
@@ -553,7 +610,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
553
610
  }
554
611
  );
555
612
  if (this.opts.autoSizeStage) this.syncStageSize();
556
- applyFilters(this.stage.imgEl, this.state.filters);
613
+ applyFilters(this.stage.imgEl, this.state.filters, this.stage.sharpenKernelEl, this.stage.sharpenFilterId);
557
614
  if (this.stage.zoomerEl) this.enableZoomer(this.stage.zoomerEl);
558
615
  this.gestureHandle = attachGestures(
559
616
  this.stage.stageEl,
@@ -735,9 +792,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
735
792
  this.commitState({ ...this.state, filters: mergeFilters(this.state.filters, filters) });
736
793
  }
737
794
  /**
738
- * Renders the current crop. A circle frame is a real clip in the output
739
- * (transparent corners on PNG/WebP); a circle exported as JPEG warns and
740
- * renders solid black corners instead, since JPEG has no alpha channel.
795
+ * Renders the current crop. A circle or rounded-rectangle frame is a real
796
+ * clip in the output (transparent corners on PNG/WebP); either shape
797
+ * exported as JPEG warns and renders solid black corners instead, since
798
+ * JPEG has no alpha channel.
741
799
  * @returns A data URL string (`type: "base64"`, the default), a `Blob`, or an `HTMLCanvasElement`.
742
800
  */
743
801
  async export(options = {}) {
@@ -746,6 +804,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
746
804
  this.state,
747
805
  this.getFrameSize(),
748
806
  this.opts.frame.shape,
807
+ this.opts.frame.cornerRadius,
808
+ this.stage.sharpenFilterId,
749
809
  options
750
810
  );
751
811
  }
@@ -806,7 +866,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
806
866
  this.state = next;
807
867
  const scale = computeCoverScale(this.naturalSize, this.getFrameSize(), next.rotation) * next.zoom;
808
868
  applyTransform(this.stage.imageLayerEl, next, scale);
809
- applyFilters(this.stage.imgEl, next.filters);
869
+ applyFilters(this.stage.imgEl, next.filters, this.stage.sharpenKernelEl, this.stage.sharpenFilterId);
810
870
  if (this.stage.zoomerEl) this.stage.zoomerEl.value = String(next.zoom);
811
871
  for (const cb of this.listeners.change) cb(this.getState());
812
872
  }