diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ff0537f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +bin +dist +lib +demo diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..51e4336 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,43 @@ +{ + "name": "rough-notation", + "version": "0.0.1", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "path-data-parser": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz", + "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==" + }, + "points-on-curve": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", + "integrity": "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==" + }, + "points-on-path": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/points-on-path/-/points-on-path-0.2.1.tgz", + "integrity": "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==", + "requires": { + "path-data-parser": "0.1.0", + "points-on-curve": "0.2.0" + } + }, + "roughjs": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.3.1.tgz", + "integrity": "sha512-m42+OBaBR7x5UhIKyjBCnWqqkaEkBKLkXvHv4pOWJXPofvMnQY4ZcFEQlqf3coKKyZN2lfWMyx7QXSg2GD7SGA==", + "requires": { + "path-data-parser": "^0.1.0", + "points-on-curve": "^0.2.0", + "points-on-path": "^0.2.1" + } + }, + "typescript": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.3.tgz", + "integrity": "sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6a04925 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "rough-notation", + "version": "0.0.1", + "description": "Annotate html", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/pshihn/rough-notation.git" + }, + "keywords": [ + "annotate", + "rough", + "sketchy" + ], + "author": "Preet Shihn", + "license": "MIT", + "bugs": { + "url": "https://github.com/pshihn/rough-notation/issues" + }, + "homepage": "https://github.com/pshihn/rough-notation#readme", + "devDependencies": { + "typescript": "^3.9.3" + }, + "dependencies": { + "roughjs": "^4.3.1" + } +} diff --git a/src/keyframes.ts b/src/keyframes.ts new file mode 100644 index 0000000..dfccfe6 --- /dev/null +++ b/src/keyframes.ts @@ -0,0 +1,14 @@ +export function ensureKeyframes() { + if (!(window as any).__rough_notation_keyframe_styles) { + const style = (window as any).__rough_notation_keyframe_styles = document.createElement('style'); + style.textContent = ` + @keyframes rough-notation-dash { + to { + stroke-dashoffset: 0; + } + } + `; + console.log('keyframe added'); + document.head.appendChild(style); + } +} \ No newline at end of file diff --git a/src/model.ts b/src/model.ts new file mode 100644 index 0000000..4a0f07e --- /dev/null +++ b/src/model.ts @@ -0,0 +1,27 @@ +export const SVG_NS = 'http://www.w3.org/2000/svg'; + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +export type RoughAnnotationType = 'underline' | 'box' | 'circle' | 'highlight' | 'strike-through' | 'crossed-off'; + +export interface RoughAnnotationConfig { + type: RoughAnnotationType; + animate?: boolean; // defaults to true + animationDuration?: number; // defaulst to 1000ms + animationDelay?: number; // default = 0 + color?: string; // defaults to currentColor + strokeWidth?: number; // default based on type + padding?: number; // defaults to 5px +} + +export interface RoughAnnotation { + isShowing(): boolean; + show(): void; + hide(): void; + remove(): void; +} \ No newline at end of file diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..e4f80f4 --- /dev/null +++ b/src/render.ts @@ -0,0 +1,149 @@ +import { Rect, RoughAnnotationConfig, SVG_NS } from './model.js'; +import { ResolvedOptions, OpSet } from 'roughjs/bin/core'; +import { line, rectangle, ellipse } from 'roughjs/bin/renderer'; + +const defaultOptions: ResolvedOptions = { + maxRandomnessOffset: 2, + roughness: 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, + seed: 0, + combineNestedSvgPaths: false, + disableMultiStroke: false, + disableMultiStrokeFill: false +}; +const singleStrokeOptions = JSON.parse(JSON.stringify(defaultOptions)); +singleStrokeOptions.disableMultiStroke = true; +const highlightOptions = JSON.parse(JSON.stringify(defaultOptions)); +highlightOptions.roughness = 3; + +export function renderAnnotation(svg: SVGSVGElement, rect: Rect, config: RoughAnnotationConfig) { + let ops: OpSet | null = null; + let strokeWidth = config.strokeWidth || 2; + const padding = (config.padding === 0) ? 0 : (config.padding || 5); + const animate = (config.animate === undefined) ? true : (!!config.animate); + + switch (config.type) { + case 'underline': { + const y = rect.y + rect.h + padding; + ops = line(rect.x, y, rect.x + rect.w, y, defaultOptions); + break; + } + case 'strike-through': { + const y = rect.y + (rect.h / 2); + ops = line(rect.x, y, rect.x + rect.w, y, defaultOptions); + break; + } + case 'box': { + const x = rect.x - padding; + const y = rect.y - padding; + const width = rect.w + (2 * padding); + const height = rect.h + (2 * padding); + ops = rectangle(x, y, width, height, singleStrokeOptions); + const ops2 = rectangle(x, y, width, height, singleStrokeOptions); + ops.ops = [...ops.ops, ...ops2.ops]; + break; + } + case 'crossed-off': { + const x = rect.x; + const y = rect.y; + const x2 = x + rect.w; + const y2 = y + rect.h; + ops = line(x, y, x2, y2, defaultOptions); + const ops2 = line(x2, y, x, y2, defaultOptions); + ops.ops = [...ops.ops, ...ops2.ops]; + break; + } + case 'circle': { + const p2 = padding * 2; + const width = rect.w + (2 * p2); + const height = rect.h + (2 * p2); + const x = rect.x - p2 + (width / 2); + const y = rect.y - p2 + (height / 2); + ops = ellipse(x, y, width, height, defaultOptions); + break; + } + case 'highlight': { + strokeWidth = rect.h * 0.95; + const y = rect.y + (rect.h / 2); + ops = line(rect.x, y, rect.x + rect.w, y, highlightOptions); + break; + } + } + + if (ops) { + const pathStrings = opsToPath(ops); + const lengths: number[] = []; + const pathElements: SVGPathElement[] = []; + let totalLength = 0; + const totalDuration = config.animationDuration === 0 ? 0 : (config.animationDuration || 500); + const initialDelay = config.animationDelay === 0 ? 0 : (config.animationDelay || 0); + + for (const d of pathStrings) { + const path = document.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', d); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', config.color || 'currentColor'); + path.setAttribute('stroke-width', `${strokeWidth}`); + 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 ? (totalDuration * (length / totalLength)) : 0; + const delay = initialDelay + 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; + } + } + } +} + +function opsToPath(drawing: OpSet): string[] { + const paths: string[] = []; + 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; +} \ No newline at end of file diff --git a/src/rough-notation.ts b/src/rough-notation.ts new file mode 100644 index 0000000..54452de --- /dev/null +++ b/src/rough-notation.ts @@ -0,0 +1,182 @@ +import { Rect, RoughAnnotationConfig, RoughAnnotation, SVG_NS } from './model.js'; +import { renderAnnotation } from './render.js'; +import { ensureKeyframes } from './keyframes.js'; + +type AnootationState = 'unattached' | 'not-showing' | 'showing'; + +class RoughAnnotationImpl implements RoughAnnotation { + private _state: AnootationState = 'unattached'; + private _config: RoughAnnotationConfig; + private _e: HTMLElement; + private _svg?: SVGSVGElement; + private _resizing = false; + private _resizeObserver?: any; // ResizeObserver is not supported in typescript std lib yet + private _lastSize?: Rect; + + constructor(e: HTMLElement, config: RoughAnnotationConfig) { + this._e = e; + this._config = config; + this.attach(); + } + + private _resizeListener = () => { + if (!this._resizing) { + this._resizing = true; + setTimeout(() => { + this._resizing = false; + if (this._state === 'showing') { + const newSize = this.computeSize(); + if (newSize && this.hasRectChanged(newSize)) { + this.show(); + } + } + }, 400); + } + } + + private attach() { + if (this._state === 'unattached' && this._e.parentElement) { + ensureKeyframes(); + const svg = this._svg = document.createElementNS(SVG_NS, 'svg'); + const style = svg.style; + style.position = 'absolute'; + style.top = '0'; + style.left = '0'; + style.overflow = 'visible'; + style.pointerEvents = 'none'; + style.width = '1px'; + style.height = '1px'; + 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._resizeObserver) { + this._resizeObserver.unobserve(this._e); + } + } + + private attachListeners() { + this.detachListeners(); + window.addEventListener('resize', this._resizeListener, { passive: true }); + if ((!this._resizeObserver) && ('ResizeObserver' in window)) { + this._resizeObserver = new (window as any).ResizeObserver((entries: any) => { + for (const entry of entries) { + let trigger = true; + if (entry.contentRect) { + const newRect = this.computeSizeWithBounds(entry.contentRect); + if (newRect && (!this.hasRectChanged(newRect))) { + trigger = false; + } + } + if (trigger) { + this._resizeListener(); + } + } + }); + } + if (this._resizeObserver) { + this._resizeObserver.observe(this._e); + } + } + + private sameInteger(a: number, b: number): boolean { + return Math.round(a) === Math.round(b); + } + + private hasRectChanged(rect: Rect): boolean { + if (this._lastSize && rect) { + return !( + this.sameInteger(rect.x, this._lastSize.x) && + this.sameInteger(rect.y, this._lastSize.y) && + this.sameInteger(rect.w, this._lastSize.w) && + this.sameInteger(rect.h, this._lastSize.h) + ); + } + return true; + } + + isShowing(): boolean { + return (this._state !== 'not-showing'); + } + + show(): void { + switch (this._state) { + case 'unattached': + break; + case 'showing': + this.hide(); + this.show(); + break; + case 'not-showing': + this.attach(); + if (this._svg) { + this.render(this._svg); + } + 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) { + const rect = this.computeSize(); + if (rect) { + renderAnnotation(svg, rect, this._config); + this._lastSize = rect; + this._state = 'showing'; + } + } + + private computeSize(): Rect | null { + return this.computeSizeWithBounds(this._e.getBoundingClientRect()); + } + + private computeSizeWithBounds(bounds: DOMRect | DOMRectReadOnly): Rect | null { + if (this._svg) { + const rect1 = this._svg.getBoundingClientRect(); + const rect2 = bounds; + + const x = (rect2.x || rect2.left) - (rect1.x || rect1.left); + const y = (rect2.y || rect2.top) - (rect1.y || rect1.top); + const w = rect2.width; + const h = rect2.height; + + return { x, y, w, h }; + } + return null; + } +} + +export function annotate(element: HTMLElement, config: RoughAnnotationConfig): RoughAnnotation { + return new RoughAnnotationImpl(element, config); +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2bfd54c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "es2015", + "moduleResolution": "node", + "lib": [ + "es2017", + "dom" + ], + "declaration": true, + "outDir": "./lib", + "baseUrl": ".", + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..c8e9cb2 --- /dev/null +++ b/tslint.json @@ -0,0 +1,61 @@ +{ + "rules": { + "arrow-parens": true, + "class-name": true, + "indent": [ + true, + "spaces", + 2 + ], + "prefer-const": true, + "no-duplicate-variable": true, + "no-eval": true, + "no-internal-module": true, + "no-trailing-whitespace": false, + "no-var-keyword": true, + "one-line": [ + true, + "check-open-brace", + "check-whitespace" + ], + "quotemark": [ + true, + "single", + "avoid-escape" + ], + "semicolon": [ + true, + "always" + ], + "trailing-comma": [ + true, + "multiline" + ], + "triple-equals": [ + true, + "allow-null-check" + ], + "typedef-whitespace": [ + true, + { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + } + ], + "variable-name": [ + true, + "ban-keywords" + ], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} \ No newline at end of file