@aznabee/freehand-ui 0.2.1 → 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 +51 -2
- package/dist/freehand-ui.cjs +147 -29
- package/dist/freehand-ui.js +147 -29
- package/dist/index.d.ts +47 -0
- package/dist/native.cjs +131 -14
- package/dist/native.d.ts +1 -0
- package/dist/native.js +131 -14
- package/dist/react.cjs +147 -29
- package/dist/react.d.ts +1 -0
- package/dist/react.js +147 -29
- package/package.json +11 -3
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
|
-
|
|
24
|
+

|
|
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)
|
package/dist/freehand-ui.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
1201
|
-
|
|
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)
|
|
1242
|
+
lines.push(lineOf(words.slice(i, i + size), marks));
|
|
1206
1243
|
}
|
|
1207
1244
|
}
|
|
1208
|
-
return lines.length ? lines : [
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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) => {
|
package/dist/freehand-ui.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
1176
|
-
|
|
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)
|
|
1217
|
+
lines.push(lineOf(words.slice(i, i + size), marks));
|
|
1181
1218
|
}
|
|
1182
1219
|
}
|
|
1183
|
-
return lines.length ? lines : [
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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) => {
|