Skip to content

Instantly share code, notes, and snippets.

@Gozala
Last active November 6, 2020 10:52
Show Gist options
  • Save Gozala/58cc14aeae44bf57636108ce9fdd2d31 to your computer and use it in GitHub Desktop.
Save Gozala/58cc14aeae44bf57636108ce9fdd2d31 to your computer and use it in GitHub Desktop.
Content Pinning
class Model {
constructor(isShiftPressed=false, marker=null, selection=null) {
this.isShiftPressed = isShiftPressed
this.marker = marker
this.selection = selection
this.tasks = []
}
clone() {
const instance = new this.constructor()
Object.keys(this).forEach(key => instance[key] = this[key])
return instance
}
merge(changes) {
return Object.assign(this.clone(), changes)
}
fx(task) {
const instance = this.clone()
instance.tasks = [...this.tasks, task]
return instance
}
transact(process) {
if (this.tasks.length > 0) {
this.tasks.forEach(task => new Promise(task).then(process.send, process.report))
const instance = this.clone()
instance.tasks = []
return instance
}
return this
}
}
const init = () => new Model()
const report = (model, error) => model.fx((succeed, fail) => console.error(error))
const update = (message, state) => {
switch (message.type) {
case 'NoOp':
return state
case 'PressShift':
return pressShift(state)
case 'ReleaseShift':
return releaseShift(state)
case 'MouseOver':
return hover(state, message.rect)
case 'MouseOut':
return hout(state, message.rect)
case 'Click':
return click(state)
default:
return report(state, `Unknown message: ${JSON.stringify(message)}`)
}
}
const drawScene = (target) => {
const element = target.ownerDocument.createElement('article')
element.style.position = 'absolute'
element.style.pointerEvents = 'none'
element.style.top = 0
element.style.left = 0
element.style.width = '100%'
element.style.height = '100%'
element.style.zIndex = 99998
element.className = 'marker-scene'
target.appendChild(element)
return element
}
const drawMarker = (target) => {
const element = target.ownerDocument.createElement('section')
element.style.position = 'absolute'
element.style.pointerEvents = 'none'
element.style.backgroundColor = 'yellow'
element.style.opacity = 0.5
element.style.display = 'none'
element.className = 'marker'
element.style.ponterEvents = 'none'
target.appendChild(element)
return element
}
const drawStateDebugger = (target) => {
const element = target.ownerDocument.createElement('pre')
element.style.position = 'fixed'
element.style.pointerEvents = 'none'
element.style.top = 0
element.style.left = 0
element.style.zIndex = 99999
element.className = 'state-debugger'
element.style.backgroundColor = 'rgba(0, 0, 0, 0.5)'
element.style.color = 'white'
target.appendChild(element)
return element
}
const renderSelection = (document, state) => {
if (state != null) {
const selection = document.getSelection()
const {documentElement, body} = document
const left = state.left + state.offsetLeft - (documentElement.scrollLeft + body.scrollLeft)
const top = state.top + state.offsetTop - (documentElement.scrollTop + body.scrollTop)
const node = document.elementFromPoint(left, top)
if (node != null) {
const range = document.createRange()
try {
selection.removeAllRanges()
range.selectNode(node)
selection.addRange(range)
} catch (error) {
console.error(node, error)
}
}
}
}
const renderStateDebugger = (output, state) => {
output.textContent = JSON.stringify(state, 2, 2)
}
const renderMarker = (element, marker, isVisible) => {
element.style.height = `${marker.height}px`
element.style.width = `${marker.width}px`
element.style.top = `${marker.top + marker.offsetTop}px`
element.style.left = `${marker.left + marker.offsetLeft}px`
element.style.display = isVisible
? 'block'
: 'none'
}
const draw = (state) => {
const scene =
document.querySelector(':root > .marker-scene') ||
drawScene(document.documentElement)
const marker =
document.querySelector(':root > .marker-scene > .marker') ||
drawMarker(scene)
const stateDebugger =
document.querySelector(':root > .state-debugger') ||
drawStateDebugger(document.documentElement)
renderMarker(marker, state.marker, state.isShiftPressed)
renderStateDebugger(stateDebugger, state)
renderSelection(document, state.selection)
}
const hover = (state, rect) => state.merge({marker: rect})
const hout = (state, rect) => state
const pressShift = state => state.merge({isShiftPressed: true})
const releaseShift = state => state.merge({isShiftPressed: false})
const click = state =>
state.isShiftPressed
? state.merge({selection: state.marker})
: state
class Program {
constructor() {
this.state = init().transact(this)
}
send(message) {
const before = this.state
try {
this.state = update(message, this.state).transact(this)
} catch (error) {
this.report(error)
}
if (before !== this.state) {
draw(this.state)
console.log(this.state)
}
}
report(error) {
console.error('Unhandled error occured', error)
}
}
const program = new Program()
const isShiftKey = event =>
event.key === 'Meta' ||
event.keyIdentifier === 'Meta' ||
event.keyCode === 224
class Rect {
constructor(width, height, top, left, offsetTop, offsetLeft) {
this.width = width
this.height = height
this.top = top
this.left = left
this.offsetTop = offsetTop
this.offsetLeft = offsetLeft
}
}
const readTargetRect = element => {
const {ownerDocument} = element
const {body, documentElement} = ownerDocument
const {width, height, top, left} = element.getBoundingClientRect()
return new Rect(width,
height,
top,
left,
documentElement.scrollTop + body.scrollTop,
documentElement.scrollLeft + body.scrollLeft)
}
if (window.decoders == null) {
window.decoders = {}
}
decoders.keydown = (event) => {
console.log(event)
if (isShiftKey(event)) {
return {type: "PressShift"}
} else {
return {type: "NoOp"}
}
}
decoders.keyup = (event) => {
if (isShiftKey(event)) {
return {type: "ReleaseShift"}
} else {
return {type: "NoOp"}
}
}
decoders.mouseover = ({target}) => {
return { type: "MouseOver", rect: readTargetRect(target) }
}
decoders.mouseout = ({target}) => {
return { type: "MouseOut", rect: readTargetRect(target) }
}
decoders.mouseup = _ => {
return { type: "Click" }
}
decoders.handleEvent = (event) => {
const decoder = decoders[event.type]
if (decoder) {
program.send(decoder(event))
}
}
document.onkeydown = decoders.handleEvent
document.onkeyup = decoders.handleEvent
document.onmouseover = decoders.handleEvent
document.onmouseout = decoders.handleEvent
document.onmouseup = decoders.handleEvent
<a href="javascript:var xhr=new XMLHttpRequest();xhr.open('GET', 'https://gist.githubusercontent.com/Gozala/58cc14aeae44bf57636108ce9fdd2d31/raw/content-pinning.js?time='+new Date().getTime(), true);xhr.send();xhr.onload=function(){eval(xhr.responseText)};void 0;">
Save to library
</a>
/* @flow */
type StringEncodedCSSSelector = string
type XPath = string
type Integer = number
type SerializedSVG = string
type FragmentSpecification =
| 'http://tools.ietf.org/rfc/rfc3236'
| 'http://tools.ietf.org/rfc/rfc3778'
| 'http://tools.ietf.org/rfc/rfc5147'
| 'http://tools.ietf.org/rfc/rfc3023'
| 'http://tools.ietf.org/rfc/rfc3870'
| 'http://tools.ietf.org/rfc/rfc7111'
| 'http://www.w3.org/TR/media-frags/'
| 'http://www.w3.org/TR/SVG/'
| 'http://www.idpf.org/epub/linking/cfi/epub-cfi.html'
type Refinement = {
refinedBy:?Selector
}
export type Selector =
& Refinement
| { type:"CssSelector", value:StringEncodedCSSSelector }
| { type:"XPathSelector", value:XPath }
| { type:"TextQuoteSelector",exact:string, prefix:string, suffix:string }
| { type:"TextPositionSelector", start:Integer, end:Integer }
| { type:"DataPositionSelector", start:Integer, end:Integer }
| { type:"SvgSelector", value:SerializedSVG }
| { type:"RangeSelector", startSelector:Selector, endSelector:Selector }
| { type:"FragmentSelector", conformsTo:FragmentSpecification, value:string }
const ELEMENT_NODE = 1
const TEXT_NODE = 3
type Indexed <item> = {
length:number,
[index:number]: item
}
const indexOfChild = <item> (child:item, children:Indexed<item>):number => {
const length = children.length
let index = 0
while (index < length) {
if (children[index] === child) {
return index
} else {
index++
}
}
return -1
}
const selectorOf = (to:Element, from:Element|Document|null=null):string => {
let target = to
let selector = ""
while (from !== target && target != null && target.nodeType === ELEMENT_NODE) {
if (target.id !== "" && target.id != null) {
selector = `> #${target.id} ${selector}`
break
}
const parent = target.parentElement
if (parent != null) {
const n = indexOfChild(target, parent.children) + 1
selector = `> ${target.localName}:nth-child(${n}) ${selector}`
} else {
selector = `> ${target.localName} ${selector}`
}
target = parent
}
return selector.substr(2)
}
class RangeSelector {
type = "RangeSelector"
startSelector:Selector
endSelector:Selector
refinedBy:?Selector
constructor(start:Selector, end:Selector, refinedBy?:Selector) {
this.startSelector = start
this.endSelector = end
this.refinedBy = refinedBy
}
}
class CSSSelector {
type:"CssSelector" = "CssSelector"
value:StringEncodedCSSSelector
refinedBy:?Selector
constructor(value:StringEncodedCSSSelector, refinedBy?:Selector) {
this.value = value
this.refinedBy = refinedBy
}
}
class TextPositionSelector {
type:"TextPositionSelector" = "TextPositionSelector"
start:Integer
end:Integer
refinedBy:?Selector
constructor(start:Integer, end:Integer, refinedBy?:Selector) {
this.start = start
this.end = end
this.refinedBy = refinedBy
}
}
class CursorPositionSelector extends TextPositionSelector {
constructor(offset:Integer) {
super(offset, offset)
}
}
const getCursorPositionSelector = (to:Node, offset:Integer, from:Node):Selector => {
const document = to.ownerDocument
const range = document.createRange()
range.setStart(from, 0)
range.setEnd(to, offset)
return new CursorPositionSelector(range.toString().length)
}
const createRangeSelector = (root:Element|Document, commonAncestor:?Element, startContainer:Node, endContainer:Node, startOffset:Integer, endOffset:Integer) => {
const anchor = commonAncestor == null
? root
: commonAncestor
const startSelector =
getCursorPositionSelector(startContainer, startOffset, anchor)
const endSelector =
getCursorPositionSelector(endContainer, endOffset, anchor)
const rangeSelector = new RangeSelector(startSelector, endSelector)
if (anchor !== root && commonAncestor != null) {
const commonAncestorSelector = selectorOf(commonAncestor, root)
return new CSSSelector(commonAncestorSelector, rangeSelector)
} else {
return rangeSelector
}
}
const toElement =
(node:Node):?Element => {
const element = node.nodeType === Node.ELEMENT_NODE /*::&& node instanceof Element*/
? node
: null
return element
}
const toText =
(node:Node):?Text => {
const text = node.nodeType === Node.TEXT_NODE /*::&& node instanceof Text*/
? node
: null
return text
}
const getRangeSelector = (range:Range):Selector => {
const {
collapsed, commonAncestorContainer,
startContainer, startOffset,
endContainer, endOffset
} = range
const root = commonAncestorContainer.ownerDocument.documentElement ||
commonAncestorContainer.ownerDocument
switch (commonAncestorContainer.nodeType) {
case TEXT_NODE: {
const selector =
createRangeSelector(root,
commonAncestorContainer.parentElement,
startContainer,
endContainer,
startOffset,
endOffset)
return selector
}
case ELEMENT_NODE: {
const selector =
createRangeSelector(root,
toElement(commonAncestorContainer),
startContainer,
endContainer,
startOffset,
endOffset)
return selector
}
default: {
const selector =
createRangeSelector(root,
null,
startContainer,
endContainer,
startOffset,
endOffset)
return selector
}
}
}
/* @flow */
class Break <state> {
value:state
constructor(value:state) {
this.value = value
}
}
type Step <state> =
| Break<state>
| state
type Reducer <state, item> =
(result:state, input:item) => Step<state>
const reduceTextNodes = <state>
(reducer:Reducer<state, Text>, root:Element, seed:state):state => {
let element:Element = root
let result:state = seed
let instruction = result
let stack:Array<number> = []
let index = 0
while (true) {
const {childNodes} = element
const {length} = childNodes
let nodeType = Node.TEXT_NODE
while (index < length) {
const child = childNodes[index]
nodeType = child.nodeType
index = index + 1
if (nodeType === Node.TEXT_NODE/*:: && child instanceof Text*/) {
status = 1
instruction = reducer(result, child)
if (instruction instanceof Break) {
return instruction.value
} else {
result = instruction
}
}
if (nodeType === Node.ELEMENT_NODE/*:: && child instanceof Element*/) {
stack.push(index)
element = child
index = 0
status = 2
break
}
}
// If loop exited because element node was reach or
// if loop exited because element had no children
// resume traversal from the stack.
if (nodeType === Node.TEXT_NODE || length === 0) {
const {parentElement} = element
if (parentElement != null && stack.length > 0) {
element = parentElement
index = stack.pop()
} else {
break
}
}
}
return result
}
type Anchor = {
node:Node,
offset:number
}
type Anchors = Map<number, Anchor>
const getAnchorsByOffsets =
(node:Element, offsets:Array<number>):Map<number, Anchor> =>
reduceTextNodes((state, text) => {
if (state.offsets.length === 0) {
return new Break(state)
} else {
const offset = state.offsets[0]
const position = state.position + text.length
if (position > offset) {
state.offsets.shift()
state.map.set(offset, {node:text, offset:offset - state.position})
state.position = position
if (state.offsets.length > 0) {
return state
} else {
return new Break(state)
}
} else {
state.position = position
return state
}
}
}, node, {map:new Map, offsets:offsets.sort(), position:0}).map
const getAnchorByOffset =
(node:Element, offset:number):Anchor|null =>
reduceTextNodes((state:{position:number,anchor:Anchor|null}, text) => {
const position = state.position + text.length
if (position > offset) {
state.anchor = {node:text, offset:offset - state.position}
return new Break(state)
} else {
state.position = position
return state
}
}, node, {position:0, anchor:null}).anchor
type RefinedCssSelector <selector> = {
type:"CssSelector",
value:string,
refinedBy:selector
}
type RangedSelector <start, end> = {
type: "RangeSelector",
startSelector:start,
endSelector:end
}
type TextPosition = {
type:"TextPositionSelector",
start:Integer,
end:Integer
}
type MarkerSelector =
| TextPosition
| RefinedCssSelector<MarkerSelector>
type SelectionRange =
| MarkerSelector
| RangedSelector<MarkerSelector, MarkerSelector>
| RefinedCssSelector<SelectionRange>
const resolveMarkerSelector = (selector:MarkerSelector, target:Element):Anchor|Error => {
while (true) {
switch (selector.type) {
case "TextPositionSelector": {
const anchor = getAnchorByOffset(target, selector.start)
if (anchor != null) {
return anchor
} else {
return new Error(`No text node found matching ${selector.start} offset`)
}
}
case "CssSelector": {
const {refinedBy, value} = selector
const node = target.querySelector(value)
if (node != null) {
target = node
selector = refinedBy
continue
} else {
return new Error(`No element found matching ${value} query`)
}
}
}
}
return new Error(`Unsupported ${selector.type} selector`)
}
const createRange = (startContainer:Node, startOffset:number, endContainer:Node, endOffset:number):Range|Error => {
try {
const range = document.createRange()
range.setStart(startContainer, startOffset)
range.setEnd(endContainer, endOffset)
return range
} catch (error) {
return error
}
}
const resloveRange = (startSelector:MarkerSelector, endSelector:MarkerSelector, commonAncestor:Element):Range|Error => {
const start = resolveMarkerSelector(startSelector, commonAncestor)
const end = resolveMarkerSelector(endSelector, commonAncestor)
if (start instanceof Error) {
return start
} else if (end instanceof Error) {
return end
} else {
return createRange(start.node, start.offset, end.node, end.offset)
}
}
const resolveRangeSelector =
(selector:SelectionRange, target:Element):Range|Error => {
while (true) {
switch (selector.type) {
case "CssSelector": {
const commonAncestor = target.querySelector(selector.value)
if (commonAncestor == null) {
return new Error(`Node node matching ${selector.value} is found`)
} else {
const refinement = selector.refinedBy
switch (refinement.type) {
case "TextPositionSelector": {
const {start, end} = refinement
const anchors =
getAnchorsByOffsets(commonAncestor, [start, end])
const startAnchor = anchors.get(start)
const endAnchor = anchors.get(end)
if (startAnchor == null) {
return Error(`No text node found matching ${start} offset`)
} else if (endAnchor == null) {
return Error(`No text node found matching ${end} offset`)
} else {
return createRange(startAnchor.node,
startAnchor.offset,
endAnchor.node,
endAnchor.offset)
}
}
case "RangeSelector": {
const {startSelector, endSelector} = refinement
return resloveRange(startSelector, endSelector, commonAncestor)
}
case "CssSelector":
selector = refinement
target = commonAncestor
continue
}
return Error(`Unsupported ${refinement.type} selector`)
}
}
case "RangeSelector": {
const {startSelector, endSelector} = selector
return resloveRange(startSelector, endSelector, target)
}
}
}
return new Error(`Unsupported ${selector.type} selector`)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment