mirror of
https://github.com/slidevjs/rough-notation.git
synced 2026-01-15 09:49:27 +01:00
279 lines
7.6 KiB
TypeScript
279 lines
7.6 KiB
TypeScript
import { Rect, RoughAnnotationConfig, RoughAnnotation, SVG_NS, RoughAnnotationGroup, DEFAULT_ANIMATION_DURATION } from './model.js';
|
|
import { renderAnnotation } from './render.js';
|
|
import { ensureKeyframes } from './keyframes.js';
|
|
import { randomSeed } from 'roughjs/bin/math';
|
|
|
|
type AnnotationState = 'unattached' | 'not-showing' | 'showing';
|
|
|
|
class RoughAnnotationImpl implements RoughAnnotation {
|
|
private _state: AnnotationState = 'unattached';
|
|
private _config: RoughAnnotationConfig;
|
|
private _resizing = false;
|
|
private _ro?: any; // ResizeObserver is not supported in typescript std lib yet
|
|
private _seed = randomSeed();
|
|
|
|
private _e: HTMLElement;
|
|
private _svg?: SVGSVGElement;
|
|
private _lastSizes: Rect[] = [];
|
|
|
|
_animationDelay = 0;
|
|
|
|
constructor(e: HTMLElement, config: RoughAnnotationConfig) {
|
|
this._e = e;
|
|
this._config = JSON.parse(JSON.stringify(config));
|
|
this.attach();
|
|
}
|
|
|
|
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 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();
|
|
}
|
|
}
|
|
|
|
private _resizeListener = () => {
|
|
if (!this._resizing) {
|
|
this._resizing = true;
|
|
setTimeout(() => {
|
|
this._resizing = false;
|
|
if (this._state === 'showing') {
|
|
if (this.haveRectsChanged()) {
|
|
this.show();
|
|
}
|
|
}
|
|
}, 400);
|
|
}
|
|
}
|
|
|
|
private attach() {
|
|
if (this._state === 'unattached' && this._e.parentElement) {
|
|
ensureKeyframes();
|
|
const svg = this._svg = document.createElementNS(SVG_NS, 'svg');
|
|
svg.setAttribute('class', 'rough-annotation');
|
|
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';
|
|
|
|
// ensure e is positioned
|
|
if (prepend) {
|
|
const computedPos = window.getComputedStyle(this._e).position;
|
|
const unpositioned = (!computedPos) || (computedPos === 'static');
|
|
if (unpositioned) {
|
|
this._e.style.position = 'relative';
|
|
}
|
|
}
|
|
this.attachListeners();
|
|
}
|
|
}
|
|
|
|
private detachListeners() {
|
|
window.removeEventListener('resize', this._resizeListener);
|
|
if (this._ro) {
|
|
this._ro.unobserve(this._e);
|
|
}
|
|
}
|
|
|
|
private attachListeners() {
|
|
this.detachListeners();
|
|
window.addEventListener('resize', this._resizeListener, { passive: true });
|
|
if ((!this._ro) && ('ResizeObserver' in window)) {
|
|
this._ro = new (window as any).ResizeObserver((entries: any) => {
|
|
for (const entry of entries) {
|
|
if (entry.contentRect) {
|
|
this._resizeListener();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
if (this._ro) {
|
|
this._ro.observe(this._e);
|
|
}
|
|
}
|
|
|
|
private haveRectsChanged(): boolean {
|
|
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;
|
|
}
|
|
|
|
private isSameRect(rect1: Rect, rect2: Rect): boolean {
|
|
const si = (a: number, b: number) => 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(): boolean {
|
|
return (this._state !== 'not-showing');
|
|
}
|
|
|
|
private pendingRefresh?: Promise<void>;
|
|
private refresh() {
|
|
if (this.isShowing() && (!this.pendingRefresh)) {
|
|
this.pendingRefresh = Promise.resolve().then(() => {
|
|
if (this.isShowing()) {
|
|
this.show();
|
|
}
|
|
delete this.pendingRefresh;
|
|
});
|
|
}
|
|
}
|
|
|
|
show(): void {
|
|
switch (this._state) {
|
|
case 'unattached':
|
|
break;
|
|
case 'showing':
|
|
this.hide();
|
|
if (this._svg) {
|
|
this.render(this._svg, true);
|
|
}
|
|
break;
|
|
case 'not-showing':
|
|
this.attach();
|
|
if (this._svg) {
|
|
this.render(this._svg, false);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
hide(): void {
|
|
if (this._svg) {
|
|
while (this._svg.lastChild) {
|
|
this._svg.removeChild(this._svg.lastChild);
|
|
}
|
|
}
|
|
this._state = 'not-showing';
|
|
}
|
|
|
|
remove(): void {
|
|
if (this._svg && this._svg.parentElement) {
|
|
this._svg.parentElement.removeChild(this._svg);
|
|
}
|
|
this._svg = undefined;
|
|
this._state = 'unattached';
|
|
this.detachListeners();
|
|
}
|
|
|
|
private render(svg: SVGSVGElement, ensureNoAnimation: boolean) {
|
|
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;
|
|
for (let i = 0; i < rects.length; i++) {
|
|
const rect = rects[i];
|
|
const ad = totalDuration * (rect.w / totalWidth);
|
|
renderAnnotation(svg, rects[i], config, delay + this._animationDelay, ad, this._seed);
|
|
delay += ad;
|
|
}
|
|
this._lastSizes = rects;
|
|
this._state = 'showing';
|
|
}
|
|
|
|
private rects(): Rect[] {
|
|
const ret: Rect[] = [];
|
|
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;
|
|
}
|
|
|
|
private svgRect(svg: SVGSVGElement, bounds: DOMRect | DOMRectReadOnly): Rect {
|
|
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
|
|
};
|
|
}
|
|
}
|
|
|
|
export function annotate(element: HTMLElement, config: RoughAnnotationConfig): RoughAnnotation {
|
|
return new RoughAnnotationImpl(element, config);
|
|
}
|
|
|
|
export function annotationGroup(annotations: RoughAnnotation[]): RoughAnnotationGroup {
|
|
let delay = 0;
|
|
for (const a of annotations) {
|
|
const ai = a as RoughAnnotationImpl;
|
|
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();
|
|
}
|
|
}
|
|
};
|
|
} |