550 lines
16 KiB
JavaScript
Raw Permalink Normal View History

2026-05-31 13:21:13 +02:00
import { randomSeed } from 'roughjs/bin/math';
import { line, ellipse, linearPath, rectangle } from 'roughjs/bin/renderer';
import { RoughGenerator } from 'roughjs/bin/generator';
const SVG_NS = "http://www.w3.org/2000/svg";
const DEFAULT_ANIMATION_DURATION = 800;
let defaultOptions = null;
function getDefaultOptions() {
if (!defaultOptions) {
const gen = new RoughGenerator();
defaultOptions = gen.defaultOptions;
}
return defaultOptions;
}
function getOptions(type, seed, overrides) {
return {
...getDefaultOptions(),
maxRandomnessOffset: 2,
roughness: type === "highlight" ? 3 : 1.5,
bowing: 1,
stroke: "#000",
strokeWidth: 1.5,
curveTightness: 0,
curveFitting: 0.95,
curveStepCount: 9,
fillStyle: "hachure",
fillWeight: -1,
hachureAngle: -41,
hachureGap: -1,
dashOffset: -1,
dashGap: -1,
zigzagOffset: -1,
// combineNestedSvgPaths: false,
disableMultiStroke: type !== "double",
disableMultiStrokeFill: false,
seed,
...overrides
};
}
function parsePadding(config) {
const p = config.padding;
if (p || p === 0) {
if (typeof p === "number") {
return [p, p, p, p];
} else if (Array.isArray(p)) {
const pa = p;
if (pa.length) {
switch (pa.length) {
case 4:
return [...pa];
case 1:
return [pa[0], pa[0], pa[0], pa[0]];
case 2:
return [...pa, ...pa];
case 3:
return [...pa, pa[1]];
default:
return [pa[0], pa[1], pa[2], pa[3]];
}
}
}
}
return [5, 5, 5, 5];
}
function renderAnnotation(svg, rect, config, animationGroupDelay, animationDuration, seed) {
const opList = [];
let strokeWidth = config.strokeWidth || 2;
const padding = parsePadding(config);
const animate = config.animate === void 0 ? true : !!config.animate;
const iterations = config.iterations || 2;
const rtl = config.rtl ? 1 : 0;
const o = getOptions("single", seed, config);
switch (config.type) {
case "underline": {
const y = rect.y + rect.h + padding[2];
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(rect.x + rect.w, y, rect.x, y, o));
else
opList.push(line(rect.x, y, rect.x + rect.w, y, o));
}
break;
}
case "strike-through": {
const y = rect.y + rect.h / 2;
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(rect.x + rect.w, y, rect.x, y, o));
else
opList.push(line(rect.x, y, rect.x + rect.w, y, o));
}
break;
}
case "box": {
const x = rect.x - padding[3];
const y = rect.y - padding[0];
const width = rect.w + (padding[1] + padding[3]);
const height = rect.h + (padding[0] + padding[2]);
for (let i = 0; i < iterations; i++)
opList.push(rectangle(x, y, width, height, o));
break;
}
case "bracket": {
const brackets = Array.isArray(config.brackets) ? config.brackets : config.brackets ? [config.brackets] : ["right"];
const lx = rect.x - padding[3] * 2;
const rx = rect.x + rect.w + padding[1] * 2;
const ty = rect.y - padding[0] * 2;
const by = rect.y + rect.h + padding[2] * 2;
for (const br of brackets) {
let points;
switch (br) {
case "bottom":
points = [
[lx, rect.y + rect.h],
[lx, by],
[rx, by],
[rx, rect.y + rect.h]
];
break;
case "top":
points = [
[lx, rect.y],
[lx, ty],
[rx, ty],
[rx, rect.y]
];
break;
case "left":
points = [
[rect.x, ty],
[lx, ty],
[lx, by],
[rect.x, by]
];
break;
case "right":
points = [
[rect.x + rect.w, ty],
[rx, ty],
[rx, by],
[rect.x + rect.w, by]
];
break;
}
if (points)
opList.push(linearPath(points, false, o));
}
break;
}
case "crossed-off": {
const x = rect.x;
const y = rect.y;
const x2 = x + rect.w;
const y2 = y + rect.h;
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(x2, y2, x, y, o));
else
opList.push(line(x, y, x2, y2, o));
}
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(x, y2, x2, y, o));
else
opList.push(line(x2, y, x, y2, o));
}
break;
}
case "circle": {
const doubleO = getOptions("double", seed, config);
const width = rect.w + (padding[1] + padding[3]);
const height = rect.h + (padding[0] + padding[2]);
const x = rect.x - padding[3] + width / 2;
const y = rect.y - padding[0] + height / 2;
const fullItr = Math.floor(iterations / 2);
const singleItr = iterations - fullItr * 2;
for (let i = 0; i < fullItr; i++)
opList.push(ellipse(x, y, width, height, doubleO));
for (let i = 0; i < singleItr; i++)
opList.push(ellipse(x, y, width, height, o));
break;
}
case "highlight": {
const o2 = getOptions("highlight", seed, config);
strokeWidth = rect.h * 0.95;
const y = rect.y + rect.h / 2;
for (let i = rtl; i < iterations + rtl; i++) {
if (i % 2)
opList.push(line(rect.x + rect.w, y, rect.x, y, o2));
else
opList.push(line(rect.x, y, rect.x + rect.w, y, o2));
}
break;
}
}
if (opList.length) {
const pathStrings = opsToPath(opList);
const lengths = [];
const pathElements = [];
let totalLength = 0;
const setAttr = (p, an, av) => p.setAttribute(an, av);
for (const d of pathStrings) {
const path = document.createElementNS(SVG_NS, "path");
setAttr(path, "d", d);
setAttr(path, "fill", "none");
setAttr(path, "stroke", config.color || "currentColor");
setAttr(path, "stroke-width", `${strokeWidth}`);
if (config.opacity !== void 0)
setAttr(path, "style", `opacity:${config.opacity}`);
if (animate) {
const length = path.getTotalLength();
lengths.push(length);
totalLength += length;
}
svg.appendChild(path);
pathElements.push(path);
}
if (animate) {
let durationOffset = 0;
for (let i = 0; i < pathElements.length; i++) {
const path = pathElements[i];
const length = lengths[i];
const duration = totalLength ? animationDuration * (length / totalLength) : 0;
const delay = animationGroupDelay + durationOffset;
const style = path.style;
style.strokeDashoffset = `${length}`;
style.strokeDasharray = `${length}`;
style.animation = `rough-notation-dash ${duration}ms ease-out ${delay}ms forwards`;
durationOffset += duration;
}
return sleep(animationDuration + animationGroupDelay);
}
}
return sleep(0);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function opsToPath(opList) {
const paths = [];
for (const drawing of opList) {
let path = "";
for (const item of drawing.ops) {
const data = item.data;
switch (item.op) {
case "move":
if (path.trim())
paths.push(path.trim());
path = `M${data[0]} ${data[1]} `;
break;
case "bcurveTo":
path += `C${data[0]} ${data[1]}, ${data[2]} ${data[3]}, ${data[4]} ${data[5]} `;
break;
case "lineTo":
path += `L${data[0]} ${data[1]} `;
break;
}
}
if (path.trim())
paths.push(path.trim());
}
return paths;
}
function ensureKeyframes() {
if (!window.__rno_kf_s) {
const style = window.__rno_kf_s = document.createElement("style");
style.textContent = `@keyframes rough-notation-dash { to { stroke-dashoffset: 0; } }`;
document.head.appendChild(style);
}
}
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
class RoughAnnotationImpl {
constructor(e, config) {
__publicField(this, "_state", "unattached");
__publicField(this, "_config");
__publicField(this, "_resizing", false);
__publicField(this, "_ro");
// ResizeObserver is not supported in typescript std lib yet
__publicField(this, "_seed", randomSeed());
__publicField(this, "_e");
__publicField(this, "_svg");
__publicField(this, "_lastSizes", []);
__publicField(this, "_animationDelay", 0);
__publicField(this, "_resizeListener", () => {
if (!this._resizing) {
this._resizing = true;
setTimeout(() => {
this._resizing = false;
if (this._state === "showing") {
if (this.haveRectsChanged())
this.show();
}
}, 400);
}
});
__publicField(this, "pendingRefresh");
this._e = e;
this._config = JSON.parse(JSON.stringify(config));
this.attach();
}
getConfig(key) {
return this._config[key];
}
setConfig(key, value) {
if (this._config[key] !== value) {
this._config[key] = value;
this.refresh();
}
}
get animate() {
return this._config.animate;
}
set animate(value) {
this._config.animate = value;
}
get animationDuration() {
return this._config.animationDuration;
}
set animationDuration(value) {
this._config.animationDuration = value;
}
get iterations() {
return this._config.iterations;
}
set iterations(value) {
this._config.iterations = value;
}
get color() {
return this._config.color;
}
set color(value) {
if (this._config.color !== value) {
this._config.color = value;
this.refresh();
}
}
get class() {
return this._config.class;
}
set class(value) {
if (this._config.class !== value) {
this._config.class = value;
if (this._svg)
this._svg.setAttribute("class", ["rough-annotation", this._config.class || ""].filter(Boolean).join(" "));
}
}
get strokeWidth() {
return this._config.strokeWidth;
}
set strokeWidth(value) {
if (this._config.strokeWidth !== value) {
this._config.strokeWidth = value;
this.refresh();
}
}
get padding() {
return this._config.padding;
}
set padding(value) {
if (this._config.padding !== value) {
this._config.padding = value;
this.refresh();
}
}
attach() {
if (this._state === "unattached" && this._e.parentElement) {
ensureKeyframes();
const svg = this._svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("class", ["rough-annotation", this._config.class || ""].filter(Boolean).join(" "));
const style = svg.style;
style.position = "absolute";
style.top = "0";
style.left = "0";
style.overflow = "visible";
style.pointerEvents = "none";
style.width = "100px";
style.height = "100px";
const prepend = this._config.type === "highlight";
this._e.insertAdjacentElement(prepend ? "beforebegin" : "afterend", svg);
this._state = "not-showing";
if (prepend) {
const computedPos = window.getComputedStyle(this._e).position;
const unpositioned = !computedPos || computedPos === "static";
if (unpositioned)
this._e.style.position = "relative";
}
this.attachListeners();
}
}
detachListeners() {
window.removeEventListener("resize", this._resizeListener);
if (this._ro)
this._ro.unobserve(this._e);
}
attachListeners() {
this.detachListeners();
window.addEventListener("resize", this._resizeListener, { passive: true });
if (!this._ro && "ResizeObserver" in window) {
this._ro = new window.ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.contentRect)
this._resizeListener();
}
});
}
if (this._ro)
this._ro.observe(this._e);
}
haveRectsChanged() {
if (this._lastSizes.length) {
const newRects = this.rects();
if (newRects.length === this._lastSizes.length) {
for (let i = 0; i < newRects.length; i++) {
if (!this.isSameRect(newRects[i], this._lastSizes[i]))
return true;
}
} else {
return true;
}
}
return false;
}
isSameRect(rect1, rect2) {
const si = (a, b) => Math.round(a) === Math.round(b);
return si(rect1.x, rect2.x) && si(rect1.y, rect2.y) && si(rect1.w, rect2.w) && si(rect1.h, rect2.h);
}
isShowing() {
return this._state !== "not-showing";
}
refresh() {
if (this.isShowing() && !this.pendingRefresh) {
this.pendingRefresh = Promise.resolve().then(() => {
if (this.isShowing())
this.show();
delete this.pendingRefresh;
});
}
}
async show() {
switch (this._state) {
case "unattached":
break;
case "showing":
this.hide();
if (this._svg)
await this.render(this._svg, true);
break;
case "not-showing":
this.attach();
if (this._svg)
await this.render(this._svg, false);
break;
}
}
hide() {
if (this._svg) {
while (this._svg.lastChild)
this._svg.removeChild(this._svg.lastChild);
}
this._state = "not-showing";
}
remove() {
if (this._svg && this._svg.parentElement)
this._svg.parentElement.removeChild(this._svg);
this._svg = void 0;
this._state = "unattached";
this.detachListeners();
}
async render(svg, ensureNoAnimation) {
let config = this._config;
if (ensureNoAnimation) {
config = JSON.parse(JSON.stringify(this._config));
config.animate = false;
}
const rects = this.rects();
let totalWidth = 0;
rects.forEach((rect) => totalWidth += rect.w);
const totalDuration = config.animationDuration || DEFAULT_ANIMATION_DURATION;
let delay = 0;
const promises = [];
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
const ad = totalDuration * (rect.w / totalWidth);
promises.push(
renderAnnotation(svg, rects[i], config, delay + this._animationDelay + (this._config.delay || 0), ad, this._seed)
);
delay += ad;
}
this._lastSizes = rects;
this._state = "showing";
return await Promise.all(promises);
}
rects() {
const ret = [];
if (this._svg) {
if (this._config.multiline) {
const elementRects = this._e.getClientRects();
for (let i = 0; i < elementRects.length; i++)
ret.push(this.svgRect(this._svg, elementRects[i]));
} else {
ret.push(this.svgRect(this._svg, this._e.getBoundingClientRect()));
}
}
return ret;
}
svgRect(svg, bounds) {
const rect1 = svg.getBoundingClientRect();
const rect2 = bounds;
return {
x: (rect2.x || rect2.left) - (rect1.x || rect1.left),
y: (rect2.y || rect2.top) - (rect1.y || rect1.top),
w: rect2.width,
h: rect2.height
};
}
}
function annotate(element, config) {
return new RoughAnnotationImpl(element, config);
}
function annotationGroup(annotations) {
let delay = 0;
for (const a of annotations) {
const ai = a;
ai._animationDelay = delay;
const duration = ai.animationDuration === 0 ? 0 : ai.animationDuration || DEFAULT_ANIMATION_DURATION;
delay += duration;
}
const list = [...annotations];
return {
show() {
for (const a of list)
a.show();
},
hide() {
for (const a of list)
a.hide();
}
};
}
export { annotate, annotationGroup };