@aznabee/freehand-ui 0.1.7 → 0.2.0

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
@@ -6,7 +6,7 @@ Wrap any existing web element with a beautiful, responsive, hand-drawn doodle la
6
6
 
7
7
  ## What it does
8
8
 
9
- `freehand-ui` adds a subtle SVG overlay on top of your UI - thin pen strokes, handwritten notes, arrows, and small decorations. It does not change your element's own styling and never blocks clicks (`pointer-events: none`).
9
+ `freehand-ui` adds a subtle SVG overlay on top of your UI - thin pen strokes, handwritten notes, arrows, and small decorations. It does **not** change your HTML layout or block clicks (`pointer-events: none`).
10
10
 
11
11
  **Good for:** call-to-action buttons, feature cards, onboarding hints, playful marketing UI.
12
12
 
@@ -19,8 +19,6 @@ Wrap any existing web element with a beautiful, responsive, hand-drawn doodle la
19
19
  | **Localised** | Notes in the visitor's own language, right-to-left included |
20
20
  | **Responsive** | Stays aligned on resize, scroll, and reflow |
21
21
 
22
- **DOM note:** the element is wrapped in a plain positioning `<div>` so the overlay can be mounted beside it (last child, so it paints on top) - the wrapped element keeps its own display and, in a flex/grid parent, its own placement, but a selector depending on it being a *direct* child of its original parent (`.parent > .card`, `:nth-child`) will no longer match. The overlay is also clipped by an `overflow: hidden` ancestor, since it no longer escapes the page via `position: fixed`.
23
-
24
22
  **Live on [Aznabee.com](https://aznabee.com)** - see it in production on the real product.
25
23
 
26
24
  ## ![Basic hand-drawn border around a card](docs/images/basic.png)
@@ -257,7 +255,7 @@ A `dotted` arrow spends its dash pattern on the dots, and one pattern cannot bot
257
255
  Notes:
258
256
 
259
257
  - **`prefers-reduced-motion: reduce` turns both gestures off** and leaves the arrow fully drawn.
260
- - The overlay moves with the element for free through ordinary layout - scrolling, a sibling loading in above it - and is only rebuilt when the element's own size actually changes. Animations are placed by how long the overlay has been on the page rather than restarted, so repositioning never replays a draw or jolts a lean mid-cycle.
258
+ - The overlay is rebuilt on every scroll and resize. Animations are placed by how long the overlay has been on the page rather than restarted, so scrolling never replays a draw or jolts a lean mid-cycle.
261
259
 
262
260
  ### Decorations
263
261
 
@@ -426,7 +424,41 @@ Server-rendered markup is unaffected: the overlay is drawn in an effect, so the
426
424
 
427
425
  - Inline object props (`note={{ text: "hi" }}`) are compared by value, so a re-render does not tear down and redraw the overlay with a new random seed.
428
426
  - `disabled` skips drawing entirely - useful behind a reduced-motion check or a feature flag.
429
- - TypeScript definitions ship with the package for both entry points.
427
+ - TypeScript definitions ship with the package for every entry point.
428
+
429
+ ---
430
+
431
+ ## React Native
432
+
433
+ The same doodles, drawn with [react-native-svg](https://github.com/software-mansion/react-native-svg). Import from the `native` entry point - it needs `react-native-svg` installed in the app.
434
+
435
+ ```bash
436
+ npm install @aznabee/freehand-ui react-native-svg
437
+ ```
438
+
439
+ ```tsx
440
+ import Doodle from "@aznabee/freehand-ui/native";
441
+
442
+ <Doodle
443
+ note={{ text: "start chat", position: "bottom", underline: false }}
444
+ arrow={{ from: "left", to: "edge", style: "curved" }}
445
+ decorations={{ types: ["heart"] }}
446
+ radius={16}
447
+ >
448
+ <TouchableOpacity onPress={start}>...</TouchableOpacity>
449
+ </Doodle>
450
+ ```
451
+
452
+ `<Doodle>` is a plain `View` - every `View` prop passes through - and takes the same options as the web component. The doodle is drawn in an absolutely positioned layer that ignores touches and never affects layout, so, as on the web, an ancestor with `overflow: "hidden"` clips it.
453
+
454
+ Differences from the web:
455
+
456
+ - **Corner radius** is not detected from the children. Pass `radius`, or it is read from the wrapper's own `borderRadius` style.
457
+ - **Handwriting** uses the platform's built-in face - Noteworthy on iOS, `casual` on Android - because nothing is fetched at runtime. Link Caveat into the app and pass `fontFamily="Caveat"` to match the web exactly.
458
+ - **Note width** is estimated from the glyphs rather than measured, which is close enough to place the note and its underline.
459
+ - **Arrows** draw on and lean as they do on the web (the lean runs on the native driver). A dotted shaft stays still instead of crawling. Both are skipped when the OS asks for reduced motion.
460
+ - **Language**: React Native has no `navigator.languages`, so pass `locale` (your app's i18n language) to choose from a multilingual note.
461
+ - `childSelector` has no equivalent.
430
462
 
431
463
  ---
432
464
 
@@ -456,7 +488,7 @@ Then open:
456
488
  - **Framework agnostic** - plain JavaScript at the core; React is an optional entry point
457
489
  - **SVG based** - hand-drawn paths, not CSS borders
458
490
  - **Non-invasive** - `pointer-events: none`, no layout changes
459
- - **Responsive** - absolutely positioned beside the element, so it tracks scroll and layout shifts for free; `ResizeObserver` covers the rest
491
+ - **Responsive** - `ResizeObserver`, scroll listeners, and `requestAnimationFrame`
460
492
  - **Lightweight** - zero runtime dependencies
461
493
 
462
494
  ---
@@ -312,6 +312,10 @@ var DEFAULT_OPTIONS = {
312
312
  */
313
313
  zIndex: null
314
314
  };
315
+ var OVERLAY_CLASS = "doodle-ui-overlay";
316
+ function isOverlayNode(node) {
317
+ return node?.classList?.contains(OVERLAY_CLASS) ?? false;
318
+ }
315
319
  function resolveElement(target) {
316
320
  if (!target) return null;
317
321
  if (typeof target === "string") {
@@ -417,45 +421,21 @@ function resolveOverlayZIndex(element, override) {
417
421
  }
418
422
  return null;
419
423
  }
420
- var ITEM_PLACEMENT_PROPERTIES = [
421
- "order",
422
- "flexGrow",
423
- "flexShrink",
424
- "flexBasis",
425
- "alignSelf",
426
- "justifySelf",
427
- "gridColumn",
428
- "gridRow",
429
- "gridArea"
430
- ];
431
- function isInlineDisplay(computed) {
432
- return computed.display.startsWith("inline");
433
- }
434
424
  function insertOverlaySvg(svg, element) {
435
- const parent = element.parentNode;
436
- if (!parent) {
437
- document.body.appendChild(svg);
438
- return null;
439
- }
440
- const wrapper = document.createElement("div");
441
- wrapper.style.position = "relative";
442
- const computed = getComputedStyle(element);
443
- wrapper.style.display = isInlineDisplay(computed) ? "inline-block" : "block";
444
- for (const property of ITEM_PLACEMENT_PROPERTIES) {
445
- const value = computed[property];
446
- if (value) wrapper.style[property] = value;
447
- }
448
- parent.replaceChild(wrapper, element);
449
- wrapper.appendChild(element);
450
- wrapper.appendChild(svg);
451
- return wrapper;
452
- }
453
- function removeOverlaySvg(svg, wrapper, element) {
425
+ const setPosition = getComputedStyle(element).position === "static";
426
+ if (setPosition) {
427
+ element.style.position = "relative";
428
+ }
429
+ element.appendChild(svg);
430
+ return setPosition;
431
+ }
432
+ function removeOverlaySvg(svg, element, clearPosition) {
454
433
  if (svg.parentNode) {
455
434
  svg.parentNode.removeChild(svg);
456
435
  }
457
- if (!wrapper || !wrapper.parentNode) return;
458
- wrapper.parentNode.replaceChild(element, wrapper);
436
+ if (clearPosition) {
437
+ element.style.position = "";
438
+ }
459
439
  }
460
440
  function syncOverlayStacking(svg, element, zIndexOverride) {
461
441
  const zIndex = resolveOverlayZIndex(element, zIndexOverride);
@@ -482,7 +462,10 @@ function averageCornerRadius(element) {
482
462
  function detectBorderRadius(element) {
483
463
  const own = averageCornerRadius(element);
484
464
  if (own > 0) return own;
485
- const child = element.children.length === 1 ? element.firstElementChild : null;
465
+ const content = Array.from(element.children).filter(
466
+ (node) => !isOverlayNode(node)
467
+ );
468
+ const child = content.length === 1 ? content[0] : null;
486
469
  if (!child) return 0;
487
470
  const outer = element.getBoundingClientRect();
488
471
  const inner = child.getBoundingClientRect();
@@ -513,22 +496,6 @@ function relativeRect(outer, inner) {
513
496
  radius: inner.radius
514
497
  };
515
498
  }
516
- function unionRects(rects, margin = 0) {
517
- if (rects.length === 0) {
518
- return { x: 0, y: 0, width: 0, height: 0, radius: 0 };
519
- }
520
- const minX = Math.min(...rects.map((r) => r.x)) - margin;
521
- const minY = Math.min(...rects.map((r) => r.y)) - margin;
522
- const maxX = Math.max(...rects.map((r) => r.x + r.width)) + margin;
523
- const maxY = Math.max(...rects.map((r) => r.y + r.height)) + margin;
524
- return {
525
- x: minX,
526
- y: minY,
527
- width: maxX - minX,
528
- height: maxY - minY,
529
- radius: 0
530
- };
531
- }
532
499
  function inflateRect(rect, amount) {
533
500
  return {
534
501
  x: rect.x - amount,
@@ -610,8 +577,13 @@ function annotationAnchor(position, rect, offset = 12) {
610
577
  return point;
611
578
  }
612
579
 
613
- // src/renderer.js
580
+ // src/svg-dom.js
614
581
  var SVG_NS = "http://www.w3.org/2000/svg";
582
+ function createSvgElement(tag) {
583
+ return document.createElementNS(SVG_NS, tag);
584
+ }
585
+
586
+ // src/renderer.js
615
587
  var XML_NS = "http://www.w3.org/XML/1998/namespace";
616
588
  var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
617
589
  var NOTE_FONT_SIZE = 19;
@@ -835,7 +807,7 @@ function createBorderBreaks(perimeter, config, random) {
835
807
  return breaks;
836
808
  }
837
809
  function createGroup(parent, x, y, rotation = 0) {
838
- const group = document.createElementNS(SVG_NS, "g");
810
+ const group = createSvgElement("g");
839
811
  group.setAttribute(
840
812
  "transform",
841
813
  `translate(${fmt(x)} ${fmt(y)}) rotate(${rotation.toFixed(1)})`
@@ -844,7 +816,7 @@ function createGroup(parent, x, y, rotation = 0) {
844
816
  return group;
845
817
  }
846
818
  function appendPath(svg, d, style) {
847
- const path = document.createElementNS(SVG_NS, "path");
819
+ const path = createSvgElement("path");
848
820
  path.setAttribute("d", d);
849
821
  path.setAttribute("fill", style.fill ?? "none");
850
822
  path.setAttribute("stroke", style.color);
@@ -859,7 +831,7 @@ function appendPath(svg, d, style) {
859
831
  return path;
860
832
  }
861
833
  function appendGlow(svg, radius, style) {
862
- const circle = document.createElementNS(SVG_NS, "circle");
834
+ const circle = createSvgElement("circle");
863
835
  circle.setAttribute("r", fmt(radius));
864
836
  circle.setAttribute("fill", style.color);
865
837
  circle.setAttribute("opacity", String(style.opacity));
@@ -1163,7 +1135,7 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context
1163
1135
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
1164
1136
  }
1165
1137
  }
1166
- const group = document.createElementNS(SVG_NS, "g");
1138
+ const group = createSvgElement("g");
1167
1139
  svg.appendChild(group);
1168
1140
  const shaftPaths = [];
1169
1141
  if (ghost) {
@@ -1253,8 +1225,8 @@ function createNoteText(svg, text, style, options = {}) {
1253
1225
  const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
1254
1226
  const lineHeight = fontSize * 1.05;
1255
1227
  const rtl = options.rtl ?? isRtlText(text);
1256
- const group = document.createElementNS(SVG_NS, "g");
1257
- const textEl = document.createElementNS(SVG_NS, "text");
1228
+ const group = createSvgElement("g");
1229
+ const textEl = createSvgElement("text");
1258
1230
  textEl.setAttribute("x", "0");
1259
1231
  textEl.setAttribute("y", "0");
1260
1232
  textEl.setAttribute("fill", style.color);
@@ -1273,7 +1245,7 @@ function createNoteText(svg, text, style, options = {}) {
1273
1245
  textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1274
1246
  }
1275
1247
  const tspans = lines.map((line, index) => {
1276
- const tspan = document.createElementNS(SVG_NS, "tspan");
1248
+ const tspan = createSvgElement("tspan");
1277
1249
  tspan.textContent = line;
1278
1250
  tspan.setAttribute("x", "0");
1279
1251
  tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
@@ -1337,8 +1309,8 @@ function placeNoteText(note, box, style, seed, options = {}) {
1337
1309
  return note.group;
1338
1310
  }
1339
1311
  function createOverlaySvg(left, top, width, height) {
1340
- const svg = document.createElementNS(SVG_NS, "svg");
1341
- svg.setAttribute("class", "doodle-ui-overlay");
1312
+ const svg = createSvgElement("svg");
1313
+ svg.setAttribute("class", OVERLAY_CLASS);
1342
1314
  svg.setAttribute("xmlns", SVG_NS);
1343
1315
  svg.setAttribute("width", String(width));
1344
1316
  svg.setAttribute("height", String(height));
@@ -1880,13 +1852,27 @@ var instances = /* @__PURE__ */ new WeakMap();
1880
1852
  function now() {
1881
1853
  return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1882
1854
  }
1883
- function localBand(overlayRect, inset = 8) {
1855
+ function localBand(originLeft, inset = 8) {
1884
1856
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1885
1857
  return {
1886
- x: -overlayRect.x + inset,
1858
+ x: -originLeft + inset,
1887
1859
  width: Math.max(0, width - inset * 2)
1888
1860
  };
1889
1861
  }
1862
+ function paddingBox(element) {
1863
+ const rect = element.getBoundingClientRect();
1864
+ const style = getComputedStyle(element);
1865
+ const left = parseFloat(style.borderLeftWidth) || 0;
1866
+ const top = parseFloat(style.borderTopWidth) || 0;
1867
+ const right = parseFloat(style.borderRightWidth) || 0;
1868
+ const bottom = parseFloat(style.borderBottomWidth) || 0;
1869
+ return {
1870
+ left: rect.left + left,
1871
+ top: rect.top + top,
1872
+ width: Math.max(0, rect.width - left - right),
1873
+ height: Math.max(0, rect.height - top - bottom)
1874
+ };
1875
+ }
1890
1876
  var DoodleOverlay = class {
1891
1877
  /**
1892
1878
  * @param {Element} element
@@ -1898,7 +1884,7 @@ var DoodleOverlay = class {
1898
1884
  this.seed = Math.floor(Math.random() * 1e9);
1899
1885
  this.startedAt = now();
1900
1886
  this.svg = null;
1901
- this.wrapper = null;
1887
+ this.setPosition = false;
1902
1888
  this.resizeObserver = null;
1903
1889
  this.mutationObserver = null;
1904
1890
  this.pendingFrame = null;
@@ -1910,14 +1896,17 @@ var DoodleOverlay = class {
1910
1896
  }
1911
1897
  mount() {
1912
1898
  this.svg = createOverlaySvg(0, 0, 0, 0);
1913
- this.wrapper = insertOverlaySvg(this.svg, this.element);
1899
+ this.setPosition = insertOverlaySvg(this.svg, this.element);
1914
1900
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1915
1901
  this.resizeObserver = new ResizeObserver(this.onResize);
1916
1902
  this.resizeObserver.observe(this.element);
1917
1903
  if (this.options.children) {
1918
1904
  this.observeChildren();
1919
1905
  }
1920
- this.mutationObserver = new MutationObserver(this.scheduleUpdate.bind(this));
1906
+ this.mutationObserver = new MutationObserver((records) => {
1907
+ const relevant = records.some((record) => !this.svg.contains(record.target));
1908
+ if (relevant) this.scheduleUpdate();
1909
+ });
1921
1910
  this.mutationObserver.observe(this.element, {
1922
1911
  childList: true,
1923
1912
  subtree: true,
@@ -1969,22 +1958,23 @@ var DoodleOverlay = class {
1969
1958
  if (!this.svg || !this.element.isConnected) return;
1970
1959
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1971
1960
  const targets = this.getTargets();
1972
- const margin = this.overlayMargin();
1973
1961
  const elapsed = now() - this.startedAt;
1974
1962
  const absoluteRects = targets.map(
1975
1963
  (target) => getElementBounds(target, padding, radius)
1976
1964
  );
1977
- const overlayRect = unionRects(absoluteRects, margin);
1978
1965
  clearSvg(this.svg);
1979
1966
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1980
- const origin = this.wrapper ? this.wrapper.getBoundingClientRect() : { left: 0, top: 0 };
1981
- this.svg.setAttribute("width", String(overlayRect.width));
1982
- this.svg.setAttribute("height", String(overlayRect.height));
1983
- this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
1984
- this.svg.style.left = `${overlayRect.x - origin.left}px`;
1985
- this.svg.style.top = `${overlayRect.y - origin.top}px`;
1986
- this.svg.style.width = `${overlayRect.width}px`;
1987
- this.svg.style.height = `${overlayRect.height}px`;
1967
+ const box = paddingBox(this.element);
1968
+ const width = Math.max(1, box.width);
1969
+ const height = Math.max(1, box.height);
1970
+ const origin = { x: box.left, y: box.top };
1971
+ this.svg.setAttribute("width", String(width));
1972
+ this.svg.setAttribute("height", String(height));
1973
+ this.svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
1974
+ this.svg.style.left = "0px";
1975
+ this.svg.style.top = "0px";
1976
+ this.svg.style.width = `${width}px`;
1977
+ this.svg.style.height = `${height}px`;
1988
1978
  const strokeStyle = { color, strokeWidth, roughness, opacity };
1989
1979
  const noteStyle = {
1990
1980
  color,
@@ -1995,12 +1985,12 @@ var DoodleOverlay = class {
1995
1985
  };
1996
1986
  const noteGroups = [];
1997
1987
  const noteLayout = {
1998
- band: localBand(overlayRect),
1988
+ band: localBand(origin.x),
1999
1989
  gap: this.options.arrow ? 40 : 14
2000
1990
  };
2001
1991
  targets.forEach((target, index) => {
2002
1992
  const absolute = absoluteRects[index];
2003
- const local = relativeRect(overlayRect, absolute);
1993
+ const local = relativeRect(origin, absolute);
2004
1994
  const seed = this.seed + index * 31;
2005
1995
  const note = this.options.note && index === 0 ? renderAnnotation(
2006
1996
  this.svg,
@@ -2046,15 +2036,6 @@ var DoodleOverlay = class {
2046
2036
  this.svg.appendChild(group);
2047
2037
  }
2048
2038
  }
2049
- /**
2050
- * Room the overlay needs beyond the element for notes, arrows and decorations.
2051
- */
2052
- overlayMargin() {
2053
- const { note, arrow, decorations } = this.options;
2054
- if (!note && !arrow && !decorations) return 48;
2055
- const { text } = resolveLocalizedText(note?.text, note?.locale);
2056
- return Math.max(96, Math.min(240, 80 + text.length * 8));
2057
- }
2058
2039
  destroy() {
2059
2040
  if (this.pendingFrame != null) {
2060
2041
  cancelFrame(this.pendingFrame);
@@ -2077,10 +2058,9 @@ var DoodleOverlay = class {
2077
2058
  }
2078
2059
  this.listeners = [];
2079
2060
  if (this.svg) {
2080
- removeOverlaySvg(this.svg, this.wrapper, this.element);
2061
+ removeOverlaySvg(this.svg, this.element, this.setPosition);
2081
2062
  }
2082
2063
  this.svg = null;
2083
- this.wrapper = null;
2084
2064
  instances.delete(this.element);
2085
2065
  }
2086
2066
  };