Last active
November 6, 2020 10:52
-
-
Save Gozala/58cc14aeae44bf57636108ce9fdd2d31 to your computer and use it in GitHub Desktop.
Content Pinning
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* @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