@aznabee/freehand-ui 0.2.0 → 0.3.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
@@ -21,7 +21,7 @@ Wrap any existing web element with a beautiful, responsive, hand-drawn doodle la
21
21
 
22
22
  **Live on [Aznabee.com](https://aznabee.com)** - see it in production on the real product.
23
23
 
24
- ## ![Basic hand-drawn border around a card](docs/images/basic.png)
24
+ ![Basic hand-drawn border around a card](docs/images/basic.png)
25
25
 
26
26
  ## Install
27
27
 
@@ -121,6 +121,32 @@ doodle(".cta", {
121
121
 
122
122
  Notes are measured from real glyph metrics and laid out so they never sit on top of the element. The underline is drawn to the measured text width; set `underline: false` for plain handwriting.
123
123
 
124
+ #### Styling a note
125
+
126
+ A note writes in the doodle's `color` by default. Give it its own look, and paint chosen words in another colour:
127
+
128
+ ```javascript
129
+ doodle(".cta", {
130
+ border: false,
131
+ note: {
132
+ text: "Start chat\nin Game Mode!",
133
+ position: "right",
134
+ color: "#ffffff", // the handwriting and its underline
135
+ fontSize: 25, // px, default 19
136
+ fontWeight: 600, // default 600
137
+ tilt: -11, // degrees, exactly; omit for a small random tilt
138
+ wordsPerLine: 0, // break only at \n (default 2 words per line)
139
+ underline: false,
140
+ highlight: { text: "Game Mode!", color: "#ffc83d", fontWeight: 700 },
141
+ },
142
+ });
143
+ ```
144
+
145
+ - **`highlight`** takes one entry or a list (`{ text, color?, fontWeight? }`; a bare string only finds the words). Every occurrence is painted, and a phrase that wraps onto the next line keeps its colour. Where two entries overlap, the earlier one wins. Words that are not in the note are ignored.
146
+ - **`\n`** in `text` starts a new line. `wordsPerLine` wraps the rest: `2` by default, `0` to break only at `\n`.
147
+ - **`tilt`** pins the rotation. Without it each note gets a small random tilt, as before.
148
+ - For a note in several languages, list the words for each language as separate highlights; only the ones that occur in the language drawn are painted.
149
+
124
150
  ### Notes in the visitor's language
125
151
 
126
152
  Write the note once per language and it is drawn in the one the visitor reads - taken from `navigator.languages`, which is what their operating system is set to. No configuration, no i18n library.
@@ -220,6 +246,23 @@ doodle(".cta", {
220
246
 
221
247
  **`to`:** `"edge"` (default) lands the tip just outside the frame; `"center"` points into the element.
222
248
 
249
+ **The arrow's pen.** It draws in the doodle's `color`, `strokeWidth`, `roughness` and `opacity`; each can be set for the arrow alone:
250
+
251
+ ```javascript
252
+ doodle(".cta", {
253
+ color: "#ffffff",
254
+ note: { text: "start chat", position: "right" },
255
+ arrow: {
256
+ color: "#ff4fa3", // any CSS colour
257
+ strokeWidth: 3.4, // line thickness, px
258
+ roughness: 1, // how shaky the pen is
259
+ opacity: 1,
260
+ },
261
+ });
262
+ ```
263
+
264
+ A setting that cannot be used (a negative width, an empty colour) is ignored and the doodle's own is kept.
265
+
223
266
  ### Arrow animation
224
267
 
225
268
  Arrows animate by default. Two gestures, both pure CSS:
@@ -493,6 +536,12 @@ Then open:
493
536
 
494
537
  ---
495
538
 
539
+ ## Contributing
540
+
541
+ Contributions are welcome - see [CONTRIBUTING.md](CONTRIBUTING.md) and the [Code of Conduct](CODE_OF_CONDUCT.md). Report vulnerabilities privately as described in [SECURITY.md](SECURITY.md). Release notes live in [CHANGELOG.md](CHANGELOG.md).
542
+
543
+ ---
544
+
496
545
  ## License
497
546
 
498
- MIT
547
+ [MIT](LICENSE)
@@ -351,6 +351,9 @@ function mergeOptions(options = {}) {
351
351
  // `animate: false` opts out, an object tunes speed and gestures
352
352
  animate: normalizeArrowAnimation(config.animate)
353
353
  };
354
+ for (const key of ["color", "strokeWidth", "roughness", "opacity"]) {
355
+ if (config[key] !== void 0) merged.arrow[key] = config[key];
356
+ }
354
357
  }
355
358
  if (merged.decorations === true) {
356
359
  merged.decorations = { count: null, types: null };
@@ -472,14 +475,23 @@ function detectBorderRadius(element) {
472
475
  const fitsTightly = Math.abs(outer.width - inner.width) <= 2 && Math.abs(outer.height - inner.height) <= 2;
473
476
  return fitsTightly ? averageCornerRadius(child) : 0;
474
477
  }
475
- function getElementBounds(element, padding = 0, radiusOverride = null) {
478
+ function screenScaleOf(element) {
479
+ const layoutWidth = (
480
+ /** @type {HTMLElement} */
481
+ element.offsetWidth
482
+ );
483
+ if (!layoutWidth) return 1;
484
+ const scale = element.getBoundingClientRect().width / layoutWidth;
485
+ return Number.isFinite(scale) && scale > 0.01 ? scale : 1;
486
+ }
487
+ function getElementBounds(element, padding = 0, radiusOverride = null, scale = 1) {
476
488
  const rect = element.getBoundingClientRect();
477
489
  const radius = radiusOverride != null ? radiusOverride : detectBorderRadius(element);
478
- const width = rect.width + padding * 2;
479
- const height = rect.height + padding * 2;
490
+ const width = rect.width / scale + padding * 2;
491
+ const height = rect.height / scale + padding * 2;
480
492
  return {
481
- x: rect.left - padding,
482
- y: rect.top - padding,
493
+ x: rect.left / scale - padding,
494
+ y: rect.top / scale - padding,
483
495
  width,
484
496
  height,
485
497
  // Grow the radius with the padding so the doodle stays concentric with the
@@ -1075,7 +1087,24 @@ function boundsOf(points) {
1075
1087
  height: Math.max(...ys) - Math.min(...ys)
1076
1088
  };
1077
1089
  }
1078
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
1090
+ function resolveArrowStyle(base, options = {}) {
1091
+ const style = { ...base };
1092
+ if (typeof options.color === "string" && options.color.trim()) {
1093
+ style.color = options.color;
1094
+ }
1095
+ if (Number.isFinite(options.strokeWidth) && options.strokeWidth > 0) {
1096
+ style.strokeWidth = options.strokeWidth;
1097
+ }
1098
+ if (Number.isFinite(options.roughness) && options.roughness >= 0) {
1099
+ style.roughness = options.roughness;
1100
+ }
1101
+ if (Number.isFinite(options.opacity)) {
1102
+ style.opacity = Math.min(1, Math.max(0, options.opacity));
1103
+ }
1104
+ return style;
1105
+ }
1106
+ function drawArrow(svg, rect, arrowOptions, baseStyle, seed, origin = null, context = {}) {
1107
+ const style = resolveArrowStyle(baseStyle, arrowOptions);
1079
1108
  const random = createRandom(seed + 17);
1080
1109
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
1081
1110
  const dx = end.x - start.x;
@@ -1195,17 +1224,69 @@ function drawUnderline(svg, x, y, width, style, random) {
1195
1224
  );
1196
1225
  }
1197
1226
  var DEFAULT_WORDS_PER_LINE = 2;
1198
- function wrapWords(text, perLine) {
1227
+ function wrapWords(text, perLine, marks = null) {
1228
+ const source = String(text);
1199
1229
  const lines = [];
1200
- for (const paragraph of String(text).split(/\r?\n/)) {
1201
- const words = paragraph.trim().split(/\s+/).filter(Boolean);
1230
+ let cursor = 0;
1231
+ for (const [index, paragraph] of source.split(/(\r?\n)/).entries()) {
1232
+ const start = cursor;
1233
+ cursor += paragraph.length;
1234
+ if (index % 2 === 1) continue;
1235
+ const words = [...paragraph.matchAll(/\S+/g)].map((m) => ({
1236
+ text: m[0],
1237
+ at: start + m.index
1238
+ }));
1202
1239
  if (!words.length) continue;
1203
1240
  const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
1204
1241
  for (let i = 0; i < words.length; i += size) {
1205
- lines.push(words.slice(i, i + size).join(" "));
1242
+ lines.push(lineOf(words.slice(i, i + size), marks));
1206
1243
  }
1207
1244
  }
1208
- return lines.length ? lines : [String(text)];
1245
+ return lines.length ? lines : [lineOf([{ text: source, at: 0 }], marks)];
1246
+ }
1247
+ function lineOf(words, marks) {
1248
+ const text = words.map((w) => w.text).join(" ");
1249
+ const result = [];
1250
+ let previous = -1;
1251
+ words.forEach((word, index) => {
1252
+ const first = marks ? marks[word.at] ?? -1 : -1;
1253
+ if (index > 0) result.push(previous === first ? first : -1);
1254
+ for (let k = 0; k < word.text.length; k++) {
1255
+ result.push(marks ? marks[word.at + k] ?? -1 : -1);
1256
+ }
1257
+ previous = marks ? marks[word.at + word.text.length - 1] ?? -1 : -1;
1258
+ });
1259
+ return { text, marks: result };
1260
+ }
1261
+ function normalizeHighlights(highlight) {
1262
+ const list = Array.isArray(highlight) ? highlight : highlight ? [highlight] : [];
1263
+ return list.map((entry) => typeof entry === "string" ? { text: entry } : entry).filter((entry) => entry && typeof entry.text === "string" && entry.text);
1264
+ }
1265
+ function highlightMarks(text, highlights) {
1266
+ if (!highlights.length) return null;
1267
+ const marks = new Int16Array(text.length).fill(-1);
1268
+ let any = false;
1269
+ highlights.forEach((highlight, index) => {
1270
+ for (let at = text.indexOf(highlight.text); at !== -1; at = text.indexOf(highlight.text, at + highlight.text.length)) {
1271
+ for (let k = at; k < at + highlight.text.length; k++) {
1272
+ if (marks[k] === -1) {
1273
+ marks[k] = index;
1274
+ any = true;
1275
+ }
1276
+ }
1277
+ }
1278
+ });
1279
+ return any ? marks : null;
1280
+ }
1281
+ function segmentsOf(line) {
1282
+ const segments = [];
1283
+ for (let i = 0; i < line.text.length; i++) {
1284
+ const highlight = line.marks[i] ?? -1;
1285
+ const last = segments[segments.length - 1];
1286
+ if (last && last.highlight === highlight) last.text += line.text[i];
1287
+ else segments.push({ text: line.text[i], highlight });
1288
+ }
1289
+ return segments;
1209
1290
  }
1210
1291
  function measureWidth(element, content, fontSize) {
1211
1292
  try {
@@ -1222,7 +1303,13 @@ function measureWidth(element, content, fontSize) {
1222
1303
  }
1223
1304
  function createNoteText(svg, text, style, options = {}) {
1224
1305
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
1225
- const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
1306
+ const highlights = normalizeHighlights(options.highlight);
1307
+ const wrapped = wrapWords(
1308
+ text,
1309
+ options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE,
1310
+ highlightMarks(String(text), highlights)
1311
+ );
1312
+ const lines = wrapped.map((line) => line.text);
1226
1313
  const lineHeight = fontSize * 1.05;
1227
1314
  const rtl = options.rtl ?? isRtlText(text);
1228
1315
  const group = createSvgElement("g");
@@ -1233,7 +1320,7 @@ function createNoteText(svg, text, style, options = {}) {
1233
1320
  textEl.setAttribute("opacity", String(style.opacity));
1234
1321
  textEl.setAttribute("font-size", String(fontSize));
1235
1322
  textEl.setAttribute("font-family", style.fontFamily || HANDWRITTEN_FONT);
1236
- textEl.setAttribute("font-weight", "600");
1323
+ textEl.setAttribute("font-weight", String(style.fontWeight ?? "600"));
1237
1324
  textEl.setAttribute("letter-spacing", "0.4");
1238
1325
  textEl.setAttribute("text-anchor", "start");
1239
1326
  textEl.setAttribute("dominant-baseline", "auto");
@@ -1244,11 +1331,25 @@ function createNoteText(svg, text, style, options = {}) {
1244
1331
  if (options.locale) {
1245
1332
  textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1246
1333
  }
1247
- const tspans = lines.map((line, index) => {
1334
+ const tspans = wrapped.map((line, index) => {
1248
1335
  const tspan = createSvgElement("tspan");
1249
- tspan.textContent = line;
1250
1336
  tspan.setAttribute("x", "0");
1251
1337
  tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
1338
+ const segments = segmentsOf(line);
1339
+ if (segments.every((segment) => segment.highlight === -1)) {
1340
+ tspan.textContent = line.text;
1341
+ } else {
1342
+ for (const segment of segments) {
1343
+ const run = createSvgElement("tspan");
1344
+ run.textContent = segment.text;
1345
+ const highlight = highlights[segment.highlight];
1346
+ if (highlight?.color) run.setAttribute("fill", highlight.color);
1347
+ if (highlight?.fontWeight != null) {
1348
+ run.setAttribute("font-weight", String(highlight.fontWeight));
1349
+ }
1350
+ tspan.appendChild(run);
1351
+ }
1352
+ }
1252
1353
  textEl.appendChild(tspan);
1253
1354
  return tspan;
1254
1355
  });
@@ -1367,7 +1468,21 @@ function nudgeIntoBand(box, guard, band) {
1367
1468
  if (!dx) return box;
1368
1469
  return { ...box, x: box.x + dx };
1369
1470
  }
1370
- function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
1471
+ function resolveNoteStyle(base, options = {}) {
1472
+ const style = { ...base };
1473
+ if (typeof options.color === "string" && options.color.trim()) {
1474
+ style.color = options.color;
1475
+ }
1476
+ if (Number.isFinite(options.fontSize) && options.fontSize > 0) {
1477
+ style.fontSize = options.fontSize;
1478
+ }
1479
+ if (typeof options.fontWeight === "number" && options.fontWeight > 0 || typeof options.fontWeight === "string" && options.fontWeight.trim()) {
1480
+ style.fontWeight = options.fontWeight;
1481
+ }
1482
+ return style;
1483
+ }
1484
+ function renderAnnotation(svg, rect, noteOptions, baseStyle, seed = 1, layout = {}) {
1485
+ const style = resolveNoteStyle(baseStyle, noteOptions ?? {});
1371
1486
  const { text, locale } = resolveLocalizedText(
1372
1487
  noteOptions?.text,
1373
1488
  noteOptions?.locale
@@ -1378,9 +1493,11 @@ function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {})
1378
1493
  const random = createRandom(seed + 9);
1379
1494
  const note = createNoteText(svg, text, style, {
1380
1495
  wordsPerLine: noteOptions.wordsPerLine,
1381
- locale
1496
+ locale,
1497
+ highlight: noteOptions.highlight
1382
1498
  });
1383
- const tilt = (random() - 0.45) * 7;
1499
+ const randomTilt = (random() - 0.45) * 7;
1500
+ const tilt = Number.isFinite(noteOptions.tilt) ? noteOptions.tilt : randomTilt;
1384
1501
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
1385
1502
  const size = {
1386
1503
  width: note.width,
@@ -1852,14 +1969,14 @@ var instances = /* @__PURE__ */ new WeakMap();
1852
1969
  function now() {
1853
1970
  return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1854
1971
  }
1855
- function localBand(originLeft, inset = 8) {
1972
+ function localBand(originLeft, inset = 8, scale = 1) {
1856
1973
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1857
1974
  return {
1858
- x: -originLeft + inset,
1859
- width: Math.max(0, width - inset * 2)
1975
+ x: -originLeft + inset / scale,
1976
+ width: Math.max(0, width - inset * 2) / scale
1860
1977
  };
1861
1978
  }
1862
- function paddingBox(element) {
1979
+ function paddingBox(element, scale = 1) {
1863
1980
  const rect = element.getBoundingClientRect();
1864
1981
  const style = getComputedStyle(element);
1865
1982
  const left = parseFloat(style.borderLeftWidth) || 0;
@@ -1867,10 +1984,10 @@ function paddingBox(element) {
1867
1984
  const right = parseFloat(style.borderRightWidth) || 0;
1868
1985
  const bottom = parseFloat(style.borderBottomWidth) || 0;
1869
1986
  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)
1987
+ left: rect.left / scale + left,
1988
+ top: rect.top / scale + top,
1989
+ width: Math.max(0, rect.width / scale - left - right),
1990
+ height: Math.max(0, rect.height / scale - top - bottom)
1874
1991
  };
1875
1992
  }
1876
1993
  var DoodleOverlay = class {
@@ -1959,12 +2076,13 @@ var DoodleOverlay = class {
1959
2076
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1960
2077
  const targets = this.getTargets();
1961
2078
  const elapsed = now() - this.startedAt;
2079
+ const scale = screenScaleOf(this.element);
1962
2080
  const absoluteRects = targets.map(
1963
- (target) => getElementBounds(target, padding, radius)
2081
+ (target) => getElementBounds(target, padding, radius, scale)
1964
2082
  );
1965
2083
  clearSvg(this.svg);
1966
2084
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1967
- const box = paddingBox(this.element);
2085
+ const box = paddingBox(this.element, scale);
1968
2086
  const width = Math.max(1, box.width);
1969
2087
  const height = Math.max(1, box.height);
1970
2088
  const origin = { x: box.left, y: box.top };
@@ -1985,7 +2103,7 @@ var DoodleOverlay = class {
1985
2103
  };
1986
2104
  const noteGroups = [];
1987
2105
  const noteLayout = {
1988
- band: localBand(origin.x),
2106
+ band: localBand(origin.x, 8, scale),
1989
2107
  gap: this.options.arrow ? 40 : 14
1990
2108
  };
1991
2109
  targets.forEach((target, index) => {
@@ -326,6 +326,9 @@ function mergeOptions(options = {}) {
326
326
  // `animate: false` opts out, an object tunes speed and gestures
327
327
  animate: normalizeArrowAnimation(config.animate)
328
328
  };
329
+ for (const key of ["color", "strokeWidth", "roughness", "opacity"]) {
330
+ if (config[key] !== void 0) merged.arrow[key] = config[key];
331
+ }
329
332
  }
330
333
  if (merged.decorations === true) {
331
334
  merged.decorations = { count: null, types: null };
@@ -447,14 +450,23 @@ function detectBorderRadius(element) {
447
450
  const fitsTightly = Math.abs(outer.width - inner.width) <= 2 && Math.abs(outer.height - inner.height) <= 2;
448
451
  return fitsTightly ? averageCornerRadius(child) : 0;
449
452
  }
450
- function getElementBounds(element, padding = 0, radiusOverride = null) {
453
+ function screenScaleOf(element) {
454
+ const layoutWidth = (
455
+ /** @type {HTMLElement} */
456
+ element.offsetWidth
457
+ );
458
+ if (!layoutWidth) return 1;
459
+ const scale = element.getBoundingClientRect().width / layoutWidth;
460
+ return Number.isFinite(scale) && scale > 0.01 ? scale : 1;
461
+ }
462
+ function getElementBounds(element, padding = 0, radiusOverride = null, scale = 1) {
451
463
  const rect = element.getBoundingClientRect();
452
464
  const radius = radiusOverride != null ? radiusOverride : detectBorderRadius(element);
453
- const width = rect.width + padding * 2;
454
- const height = rect.height + padding * 2;
465
+ const width = rect.width / scale + padding * 2;
466
+ const height = rect.height / scale + padding * 2;
455
467
  return {
456
- x: rect.left - padding,
457
- y: rect.top - padding,
468
+ x: rect.left / scale - padding,
469
+ y: rect.top / scale - padding,
458
470
  width,
459
471
  height,
460
472
  // Grow the radius with the padding so the doodle stays concentric with the
@@ -1050,7 +1062,24 @@ function boundsOf(points) {
1050
1062
  height: Math.max(...ys) - Math.min(...ys)
1051
1063
  };
1052
1064
  }
1053
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
1065
+ function resolveArrowStyle(base, options = {}) {
1066
+ const style = { ...base };
1067
+ if (typeof options.color === "string" && options.color.trim()) {
1068
+ style.color = options.color;
1069
+ }
1070
+ if (Number.isFinite(options.strokeWidth) && options.strokeWidth > 0) {
1071
+ style.strokeWidth = options.strokeWidth;
1072
+ }
1073
+ if (Number.isFinite(options.roughness) && options.roughness >= 0) {
1074
+ style.roughness = options.roughness;
1075
+ }
1076
+ if (Number.isFinite(options.opacity)) {
1077
+ style.opacity = Math.min(1, Math.max(0, options.opacity));
1078
+ }
1079
+ return style;
1080
+ }
1081
+ function drawArrow(svg, rect, arrowOptions, baseStyle, seed, origin = null, context = {}) {
1082
+ const style = resolveArrowStyle(baseStyle, arrowOptions);
1054
1083
  const random = createRandom(seed + 17);
1055
1084
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
1056
1085
  const dx = end.x - start.x;
@@ -1170,17 +1199,69 @@ function drawUnderline(svg, x, y, width, style, random) {
1170
1199
  );
1171
1200
  }
1172
1201
  var DEFAULT_WORDS_PER_LINE = 2;
1173
- function wrapWords(text, perLine) {
1202
+ function wrapWords(text, perLine, marks = null) {
1203
+ const source = String(text);
1174
1204
  const lines = [];
1175
- for (const paragraph of String(text).split(/\r?\n/)) {
1176
- const words = paragraph.trim().split(/\s+/).filter(Boolean);
1205
+ let cursor = 0;
1206
+ for (const [index, paragraph] of source.split(/(\r?\n)/).entries()) {
1207
+ const start = cursor;
1208
+ cursor += paragraph.length;
1209
+ if (index % 2 === 1) continue;
1210
+ const words = [...paragraph.matchAll(/\S+/g)].map((m) => ({
1211
+ text: m[0],
1212
+ at: start + m.index
1213
+ }));
1177
1214
  if (!words.length) continue;
1178
1215
  const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
1179
1216
  for (let i = 0; i < words.length; i += size) {
1180
- lines.push(words.slice(i, i + size).join(" "));
1217
+ lines.push(lineOf(words.slice(i, i + size), marks));
1181
1218
  }
1182
1219
  }
1183
- return lines.length ? lines : [String(text)];
1220
+ return lines.length ? lines : [lineOf([{ text: source, at: 0 }], marks)];
1221
+ }
1222
+ function lineOf(words, marks) {
1223
+ const text = words.map((w) => w.text).join(" ");
1224
+ const result = [];
1225
+ let previous = -1;
1226
+ words.forEach((word, index) => {
1227
+ const first = marks ? marks[word.at] ?? -1 : -1;
1228
+ if (index > 0) result.push(previous === first ? first : -1);
1229
+ for (let k = 0; k < word.text.length; k++) {
1230
+ result.push(marks ? marks[word.at + k] ?? -1 : -1);
1231
+ }
1232
+ previous = marks ? marks[word.at + word.text.length - 1] ?? -1 : -1;
1233
+ });
1234
+ return { text, marks: result };
1235
+ }
1236
+ function normalizeHighlights(highlight) {
1237
+ const list = Array.isArray(highlight) ? highlight : highlight ? [highlight] : [];
1238
+ return list.map((entry) => typeof entry === "string" ? { text: entry } : entry).filter((entry) => entry && typeof entry.text === "string" && entry.text);
1239
+ }
1240
+ function highlightMarks(text, highlights) {
1241
+ if (!highlights.length) return null;
1242
+ const marks = new Int16Array(text.length).fill(-1);
1243
+ let any = false;
1244
+ highlights.forEach((highlight, index) => {
1245
+ for (let at = text.indexOf(highlight.text); at !== -1; at = text.indexOf(highlight.text, at + highlight.text.length)) {
1246
+ for (let k = at; k < at + highlight.text.length; k++) {
1247
+ if (marks[k] === -1) {
1248
+ marks[k] = index;
1249
+ any = true;
1250
+ }
1251
+ }
1252
+ }
1253
+ });
1254
+ return any ? marks : null;
1255
+ }
1256
+ function segmentsOf(line) {
1257
+ const segments = [];
1258
+ for (let i = 0; i < line.text.length; i++) {
1259
+ const highlight = line.marks[i] ?? -1;
1260
+ const last = segments[segments.length - 1];
1261
+ if (last && last.highlight === highlight) last.text += line.text[i];
1262
+ else segments.push({ text: line.text[i], highlight });
1263
+ }
1264
+ return segments;
1184
1265
  }
1185
1266
  function measureWidth(element, content, fontSize) {
1186
1267
  try {
@@ -1197,7 +1278,13 @@ function measureWidth(element, content, fontSize) {
1197
1278
  }
1198
1279
  function createNoteText(svg, text, style, options = {}) {
1199
1280
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
1200
- const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
1281
+ const highlights = normalizeHighlights(options.highlight);
1282
+ const wrapped = wrapWords(
1283
+ text,
1284
+ options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE,
1285
+ highlightMarks(String(text), highlights)
1286
+ );
1287
+ const lines = wrapped.map((line) => line.text);
1201
1288
  const lineHeight = fontSize * 1.05;
1202
1289
  const rtl = options.rtl ?? isRtlText(text);
1203
1290
  const group = createSvgElement("g");
@@ -1208,7 +1295,7 @@ function createNoteText(svg, text, style, options = {}) {
1208
1295
  textEl.setAttribute("opacity", String(style.opacity));
1209
1296
  textEl.setAttribute("font-size", String(fontSize));
1210
1297
  textEl.setAttribute("font-family", style.fontFamily || HANDWRITTEN_FONT);
1211
- textEl.setAttribute("font-weight", "600");
1298
+ textEl.setAttribute("font-weight", String(style.fontWeight ?? "600"));
1212
1299
  textEl.setAttribute("letter-spacing", "0.4");
1213
1300
  textEl.setAttribute("text-anchor", "start");
1214
1301
  textEl.setAttribute("dominant-baseline", "auto");
@@ -1219,11 +1306,25 @@ function createNoteText(svg, text, style, options = {}) {
1219
1306
  if (options.locale) {
1220
1307
  textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1221
1308
  }
1222
- const tspans = lines.map((line, index) => {
1309
+ const tspans = wrapped.map((line, index) => {
1223
1310
  const tspan = createSvgElement("tspan");
1224
- tspan.textContent = line;
1225
1311
  tspan.setAttribute("x", "0");
1226
1312
  tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
1313
+ const segments = segmentsOf(line);
1314
+ if (segments.every((segment) => segment.highlight === -1)) {
1315
+ tspan.textContent = line.text;
1316
+ } else {
1317
+ for (const segment of segments) {
1318
+ const run = createSvgElement("tspan");
1319
+ run.textContent = segment.text;
1320
+ const highlight = highlights[segment.highlight];
1321
+ if (highlight?.color) run.setAttribute("fill", highlight.color);
1322
+ if (highlight?.fontWeight != null) {
1323
+ run.setAttribute("font-weight", String(highlight.fontWeight));
1324
+ }
1325
+ tspan.appendChild(run);
1326
+ }
1327
+ }
1227
1328
  textEl.appendChild(tspan);
1228
1329
  return tspan;
1229
1330
  });
@@ -1342,7 +1443,21 @@ function nudgeIntoBand(box, guard, band) {
1342
1443
  if (!dx) return box;
1343
1444
  return { ...box, x: box.x + dx };
1344
1445
  }
1345
- function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
1446
+ function resolveNoteStyle(base, options = {}) {
1447
+ const style = { ...base };
1448
+ if (typeof options.color === "string" && options.color.trim()) {
1449
+ style.color = options.color;
1450
+ }
1451
+ if (Number.isFinite(options.fontSize) && options.fontSize > 0) {
1452
+ style.fontSize = options.fontSize;
1453
+ }
1454
+ if (typeof options.fontWeight === "number" && options.fontWeight > 0 || typeof options.fontWeight === "string" && options.fontWeight.trim()) {
1455
+ style.fontWeight = options.fontWeight;
1456
+ }
1457
+ return style;
1458
+ }
1459
+ function renderAnnotation(svg, rect, noteOptions, baseStyle, seed = 1, layout = {}) {
1460
+ const style = resolveNoteStyle(baseStyle, noteOptions ?? {});
1346
1461
  const { text, locale } = resolveLocalizedText(
1347
1462
  noteOptions?.text,
1348
1463
  noteOptions?.locale
@@ -1353,9 +1468,11 @@ function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {})
1353
1468
  const random = createRandom(seed + 9);
1354
1469
  const note = createNoteText(svg, text, style, {
1355
1470
  wordsPerLine: noteOptions.wordsPerLine,
1356
- locale
1471
+ locale,
1472
+ highlight: noteOptions.highlight
1357
1473
  });
1358
- const tilt = (random() - 0.45) * 7;
1474
+ const randomTilt = (random() - 0.45) * 7;
1475
+ const tilt = Number.isFinite(noteOptions.tilt) ? noteOptions.tilt : randomTilt;
1359
1476
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
1360
1477
  const size = {
1361
1478
  width: note.width,
@@ -1827,14 +1944,14 @@ var instances = /* @__PURE__ */ new WeakMap();
1827
1944
  function now() {
1828
1945
  return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1829
1946
  }
1830
- function localBand(originLeft, inset = 8) {
1947
+ function localBand(originLeft, inset = 8, scale = 1) {
1831
1948
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1832
1949
  return {
1833
- x: -originLeft + inset,
1834
- width: Math.max(0, width - inset * 2)
1950
+ x: -originLeft + inset / scale,
1951
+ width: Math.max(0, width - inset * 2) / scale
1835
1952
  };
1836
1953
  }
1837
- function paddingBox(element) {
1954
+ function paddingBox(element, scale = 1) {
1838
1955
  const rect = element.getBoundingClientRect();
1839
1956
  const style = getComputedStyle(element);
1840
1957
  const left = parseFloat(style.borderLeftWidth) || 0;
@@ -1842,10 +1959,10 @@ function paddingBox(element) {
1842
1959
  const right = parseFloat(style.borderRightWidth) || 0;
1843
1960
  const bottom = parseFloat(style.borderBottomWidth) || 0;
1844
1961
  return {
1845
- left: rect.left + left,
1846
- top: rect.top + top,
1847
- width: Math.max(0, rect.width - left - right),
1848
- height: Math.max(0, rect.height - top - bottom)
1962
+ left: rect.left / scale + left,
1963
+ top: rect.top / scale + top,
1964
+ width: Math.max(0, rect.width / scale - left - right),
1965
+ height: Math.max(0, rect.height / scale - top - bottom)
1849
1966
  };
1850
1967
  }
1851
1968
  var DoodleOverlay = class {
@@ -1934,12 +2051,13 @@ var DoodleOverlay = class {
1934
2051
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1935
2052
  const targets = this.getTargets();
1936
2053
  const elapsed = now() - this.startedAt;
2054
+ const scale = screenScaleOf(this.element);
1937
2055
  const absoluteRects = targets.map(
1938
- (target) => getElementBounds(target, padding, radius)
2056
+ (target) => getElementBounds(target, padding, radius, scale)
1939
2057
  );
1940
2058
  clearSvg(this.svg);
1941
2059
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1942
- const box = paddingBox(this.element);
2060
+ const box = paddingBox(this.element, scale);
1943
2061
  const width = Math.max(1, box.width);
1944
2062
  const height = Math.max(1, box.height);
1945
2063
  const origin = { x: box.left, y: box.top };
@@ -1960,7 +2078,7 @@ var DoodleOverlay = class {
1960
2078
  };
1961
2079
  const noteGroups = [];
1962
2080
  const noteLayout = {
1963
- band: localBand(origin.x),
2081
+ band: localBand(origin.x, 8, scale),
1964
2082
  gap: this.options.arrow ? 40 : 14
1965
2083
  };
1966
2084
  targets.forEach((target, index) => {