UNPKG

foliate-js

Version:
230 lines (226 loc) 9.5 kB
const createSVGElement = tag => document.createElementNS('http://www.w3.org/2000/svg', tag) export class Overlayer { #svg = createSVGElement('svg') #map = new Map() #doc = null constructor(doc) { this.#doc = doc Object.assign(this.#svg.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', pointerEvents: 'none', }) } get element() { return this.#svg } get #zoom() { // Safari does not zoom the client rects, while Chrome, Edge and Firefox does if (/^((?!chrome|android).)*AppleWebKit/i.test(navigator.userAgent) && !window.chrome) { return window.getComputedStyle(this.#doc.body).zoom || 1.0 } return 1.0 } #splitRangeByParagraph(range) { const ancestor = range.commonAncestorContainer const paragraphs = Array.from(ancestor.querySelectorAll?.('p') || []) if (paragraphs.length === 0) return [range] const splitRanges = [] paragraphs.forEach((p) => { const pRange = document.createRange() if (range.intersectsNode(p)) { pRange.selectNodeContents(p) if (pRange.compareBoundaryPoints(Range.START_TO_START, range) < 0) { pRange.setStart(range.startContainer, range.startOffset) } if (pRange.compareBoundaryPoints(Range.END_TO_END, range) > 0) { pRange.setEnd(range.endContainer, range.endOffset) } splitRanges.push(pRange) } }) return splitRanges } add(key, range, draw, options) { if (this.#map.has(key)) this.remove(key) if (typeof range === 'function') range = range(this.#svg.getRootNode()) const zoom = this.#zoom let rects = [] this.#splitRangeByParagraph(range).forEach((pRange) => { const pRects = Array.from(pRange.getClientRects()).map(rect => ({ left: rect.left * zoom, top: rect.top * zoom, right: rect.right * zoom, bottom: rect.bottom * zoom, width: rect.width * zoom, height: rect.height * zoom, })) rects = rects.concat(pRects) }) const element = draw(rects, options) this.#svg.append(element) this.#map.set(key, { range, draw, options, element, rects }) } remove(key) { if (!this.#map.has(key)) return this.#svg.removeChild(this.#map.get(key).element) this.#map.delete(key) } redraw() { for (const obj of this.#map.values()) { const { range, draw, options, element } = obj this.#svg.removeChild(element) const zoom = this.#zoom let rects = [] this.#splitRangeByParagraph(range).forEach((pRange) => { const pRects = Array.from(pRange.getClientRects()).map(rect => ({ left: rect.left * zoom, top: rect.top * zoom, right: rect.right * zoom, bottom: rect.bottom * zoom, width: rect.width * zoom, height: rect.height * zoom, })) rects = rects.concat(pRects) }) const el = draw(rects, options) this.#svg.append(el) obj.element = el obj.rects = rects } } hitTest({ x, y }) { const arr = Array.from(this.#map.entries()) // loop in reverse to hit more recently added items first for (let i = arr.length - 1; i >= 0; i--) { const [key, obj] = arr[i] for (const { left, top, right, bottom } of obj.rects) if (top <= y && left <= x && bottom > y && right > x) return [key, obj.range] } return [] } static underline(rects, options = {}) { const { color = 'red', width: strokeWidth = 2, padding = 0, writingMode } = options const g = createSVGElement('g') g.setAttribute('fill', color) if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') for (const { right, top, height } of rects) { const el = createSVGElement('rect') el.setAttribute('x', right - strokeWidth / 2 + padding) el.setAttribute('y', top) el.setAttribute('height', height) el.setAttribute('width', strokeWidth) g.append(el) } else for (const { left, bottom, width } of rects) { const el = createSVGElement('rect') el.setAttribute('x', left) el.setAttribute('y', bottom - strokeWidth / 2 + padding) el.setAttribute('height', strokeWidth) el.setAttribute('width', width) g.append(el) } return g } static strikethrough(rects, options = {}) { const { color = 'red', width: strokeWidth = 2, writingMode } = options const g = createSVGElement('g') g.setAttribute('fill', color) if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') for (const { right, left, top, height } of rects) { const el = createSVGElement('rect') el.setAttribute('x', (right + left) / 2) el.setAttribute('y', top) el.setAttribute('height', height) el.setAttribute('width', strokeWidth) g.append(el) } else for (const { left, top, bottom, width } of rects) { const el = createSVGElement('rect') el.setAttribute('x', left) el.setAttribute('y', (top + bottom) / 2) el.setAttribute('height', strokeWidth) el.setAttribute('width', width) g.append(el) } return g } static squiggly(rects, options = {}) { const { color = 'red', width: strokeWidth = 2, padding = 0, writingMode } = options const g = createSVGElement('g') g.setAttribute('fill', 'none') g.setAttribute('stroke', color) g.setAttribute('stroke-width', strokeWidth) const block = strokeWidth * 1.5 if (writingMode === 'vertical-rl' || writingMode === 'vertical-lr') for (const { right, top, height } of rects) { const el = createSVGElement('path') const n = Math.round(height / block / 1.5) const inline = height / n const ls = Array.from({ length: n }, (_, i) => `l${i % 2 ? -block : block} ${inline}`).join('') el.setAttribute('d', `M${right - strokeWidth / 2 + padding} ${top}${ls}`) g.append(el) } else for (const { left, bottom, width } of rects) { const el = createSVGElement('path') const n = Math.round(width / block / 1.5) const inline = width / n const ls = Array.from({ length: n }, (_, i) => `l${inline} ${i % 2 ? block : -block}`).join('') el.setAttribute('d', `M${left} ${bottom + strokeWidth / 2 + padding}${ls}`) g.append(el) } return g } static highlight(rects, options = {}) { const { color = 'red', padding = 0 } = options const g = createSVGElement('g') g.setAttribute('fill', color) g.style.opacity = 'var(--overlayer-highlight-opacity, .3)' g.style.mixBlendMode = 'var(--overlayer-highlight-blend-mode, normal)' for (const { left, top, height, width } of rects) { const el = createSVGElement('rect') el.setAttribute('x', left - padding) el.setAttribute('y', top - padding) el.setAttribute('height', height + padding * 2) el.setAttribute('width', width + padding * 2) g.append(el) } return g } static outline(rects, options = {}) { const { color = 'red', width: strokeWidth = 3, padding = 0, radius = 3 } = options const g = createSVGElement('g') g.setAttribute('fill', 'none') g.setAttribute('stroke', color) g.setAttribute('stroke-width', strokeWidth) for (const { left, top, height, width } of rects) { const el = createSVGElement('rect') el.setAttribute('x', left - padding) el.setAttribute('y', top - padding) el.setAttribute('height', height + padding * 2) el.setAttribute('width', width + padding * 2) el.setAttribute('rx', radius) g.append(el) } return g } // make an exact copy of an image in the overlay // one can then apply filters to the entire element, without affecting them; // it's a bit silly and probably better to just invert images twice // (though the color will be off in that case if you do heu-rotate) static copyImage([rect], options = {}) { const { src } = options const image = createSVGElement('image') const { left, top, height, width } = rect image.setAttribute('href', src) image.setAttribute('x', left) image.setAttribute('y', top) image.setAttribute('height', height) image.setAttribute('width', width) return image } }