import {html, css, RenderOptions} from "lit"
import {action, LitElementWw} from "@webwriter/lit"
import {customElement, eventOptions, property} from "lit/decorators.js"
import {styleMap} from "lit/directives/style-map.js"
import "@shoelace-style/shoelace/dist/themes/light.css"
import SlIconButton from "@shoelace-style/shoelace/dist/components/icon-button/icon-button.component.js"
import LOCALIZE from "../../localization/generated"
import {msg} from "@lit/localize"
/**
* @param {!Node} node
* @param {boolean=} optimized
* @return {string}
*/
export const xPath = function (node, optimized=false) {
if (node.nodeType === Node.DOCUMENT_NODE) {
return '/';
}
const steps = [];
let contextNode = node;
while (contextNode) {
const step = _xPathValue(contextNode, optimized);
if (!step) {
break;
} // Error - bail out early.
steps.push(step);
if (step.optimized) {
break;
}
contextNode = contextNode.parentNode;
}
steps.reverse();
return (steps.length && steps[0].optimized ? '' : '/') + steps.join('/');
};
/**
* @param {!Node} node
* @param {boolean=} optimized
* @return {?Step}
*/
const _xPathValue = function (node, optimized) {
let ownValue;
const ownIndex = _xPathIndex(node);
if (ownIndex === -1) {
return null;
} // Error.
switch (node.nodeType) {
case Node.ELEMENT_NODE:
if (optimized && node.getAttribute('id')) {
return new Step('//*[@id="' + node.getAttribute('id') + '"]', true);
}
ownValue = node.localName;
break;
case Node.ATTRIBUTE_NODE:
ownValue = '@' + node.nodeName;
break;
case Node.TEXT_NODE:
case Node.CDATA_SECTION_NODE:
ownValue = 'text()';
break;
case Node.PROCESSING_INSTRUCTION_NODE:
ownValue = 'processing-instruction()';
break;
case Node.COMMENT_NODE:
ownValue = 'comment()';
break;
case Node.DOCUMENT_NODE:
ownValue = '';
break;
default:
ownValue = '';
break;
}
if (ownIndex > 0) {
ownValue += '[' + ownIndex + ']';
}
return new Step(ownValue, node.nodeType === Node.DOCUMENT_NODE);
};
/**
* @param {!Node} node
* @return {number}
*/
const _xPathIndex = function (node) {
// Returns -1 in case of error, 0 if no siblings matching the same expression,
// otherwise.
function areNodesSimilar(left, right) {
if (left === right) {
return true;
}
if (left.nodeType === Node.ELEMENT_NODE && right.nodeType === Node.ELEMENT_NODE) {
return left.localName === right.localName;
}
if (left.nodeType === right.nodeType) {
return true;
}
// XPath treats CDATA as text nodes.
const leftType = left.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : left.nodeType;
const rightType = right.nodeType === Node.CDATA_SECTION_NODE ? Node.TEXT_NODE : right.nodeType;
return leftType === rightType;
}
const siblings = node.parentNode ? node.parentNode.children : null;
if (!siblings) {
return 0;
} // Root node - no siblings.
let hasSameNamedElements;
for (let i = 0; i < siblings.length; ++i) {
if (areNodesSimilar(node, siblings[i]) && siblings[i] !== node) {
hasSameNamedElements = true;
break;
}
}
if (!hasSameNamedElements) {
return 0;
}
let ownIndex = 1; // XPath indices start with 1.
for (let i = 0; i < siblings.length; ++i) {
if (areNodesSimilar(node, siblings[i])) {
if (siblings[i] === node) {
return ownIndex;
}
++ownIndex;
}
}
return -1; // An error occurred: |node| not found in parent's children.
};
/**
* @unrestricted
*/
const Step = class {
constructor(readonly value: string, readonly optimized=false) {}
toString() {
return this.value;
}
};
function getCaretPositionFromPoint(e: PointerEvent) {
let range: Range | null;
let textNode: Text;
let offset: number;
if ((document as any).caretPositionFromPoint) {
range = (document as any).caretPositionFromPoint(e.clientX, e.clientY);
textNode = (range as any).offsetNode;
offset = (range as any).offset;
return {textNode, offset}
} else if (document.caretRangeFromPoint) {
// Use WebKit-proprietary fallback method
range = document.caretRangeFromPoint(e.clientX, e.clientY);
textNode = range.startContainer as Text;
offset = range.startOffset;
return {textNode, offset}
} else {
throw Error("Both 'caretPositionFromPoint' and 'caretRangeFromPoint' are unsupported")
}
}
const toAttributeRange = (ranges: SerializableRange[]) => {
return JSON.stringify(ranges)
}
const fromAttributeRange = (attr?: string) => {
if(!attr) {
return []
}
const ranges = JSON.parse(attr) as {startContainer: string, startOffset: number, endContainer?: string, endOffset: number}[]
return ranges.map(range => new SerializableRange(range))
}
type SerializableRangeLike = SerializableRange | {startContainer: string, startOffset: number, endContainer?: string, endOffset: number}
class SerializableRange extends Range {
constructor(value?: string | SerializableRangeLike) {
super()
if(value instanceof SerializableRange) {
return value
}
else if(value) {
const {startContainer, startOffset, endContainer, endOffset} = typeof value === "string"? JSON.parse(value) as {startContainer: string, startOffset: number, endContainer?: string, endOffset: number}: value
const startNode = document.evaluate(startContainer, document, null, 9, null).singleNodeValue
startNode && this.setStart(startNode, startOffset)
const endNode = document.evaluate(endContainer ?? startContainer, document, null, 9, null).singleNodeValue
endNode && this.setEnd(endNode, endOffset)
}
}
toJSON() {
return this.startContainer === this.endContainer
? {
startContainer: xPath(this.startContainer),
startOffset: this.startOffset,
endOffset: this.endOffset
}
: {
startContainer: xPath(this.startContainer),
startOffset: this.startOffset,
endContainer: xPath(this.endContainer),
endOffset: this.endOffset
}
}
toString() {
return JSON.stringify(this.toJSON())
}
}
declare global {interface HTMLElementTagNameMap {
"webwriter-mark": WebwriterMark;
}}
@customElement("webwriter-mark")
export class WebwriterMark extends LitElementWw {
localize = LOCALIZE
static shadowRootOptions = {...LitElementWw.shadowRootOptions, delegatesFocus: false}
// @ts-ignore: Experimental API
static highlightValue = new Highlight()
// @ts-ignore: Experimental API
static highlightSolution = new Highlight()
static {
// @ts-ignore: Experimental API
CSS.highlights.set("webwriter-mark-solution", this.highlightSolution)
// @ts-ignore: Experimental API
CSS.highlights.set("webwriter-mark-value", this.highlightValue)
}
static scopedElements = {
"sl-icon-button": SlIconButton
}
static styles = css`
:host {
min-height: 1rem;
position: relative;
}
slot {
display: block;
cursor: text;
}
:host(:not([contenteditable=true]):not([contenteditable=""])) slot {
cursor: pointer;
user-select: none;
}
#highlight {
position: absolute;
right: 0;
top: 0;
background: rgba(255, 255, 255, 0.85)
}
slot[data-empty]:after {
content: var(--ww-placeholder);
position: absolute;
left: 0;
top: 0;
color: darkgray;
pointer-events: none;
user-select: none;
}
#highlight::part(base):hover {
background: yellow;
}
:host ::highlight(webwriter-mark-value) {
background-color: yellow;
}
:host ::highlight(webwriter-mark-solution) {
background-color: #79b521;
}
#highlight[data-highlighting]::part(base) {
background-color: lightyellow;
}
:host(:has(#highlight[data-highlighting])) ::selection {
background: lightyellow !important;
}
:host([highlighting]) #highlight::part(base) {
background: yellow;
}
`
@property({type: Boolean, attribute: true, reflect: true})
accessor highlighting = false
#solution: SerializableRange[] = []
get solution(): SerializableRange[] {
return this.#solution
}
@property({attribute: false})
set solution(value: SerializableRangeLike[]) {
this.#updateHighlight("solution", value.map(v => new SerializableRange(v)))
this.requestUpdate("solution")
}
#value: SerializableRange[] = []
get value(): SerializableRange[] {
return this.#value
}
@property({attribute: true, reflect: true, converter: {toAttribute: toAttributeRange, fromAttribute: fromAttributeRange}})
set value(value: SerializableRangeLike[]) {
this.#updateHighlight("value", value.map(v => new SerializableRange(v)))
this.requestUpdate("value")
}
#updateHighlight(key: "value" | "solution", value: SerializableRange[]) {
const prev = this[key]
const finalValue = value.filter(range => {
const isContained = value.some(otherRange => {
if(range === otherRange) {
return
}
const startsWithin = range.compareBoundaryPoints(Range.START_TO_START, otherRange) > -1
const endsWithin = range.compareBoundaryPoints(Range.END_TO_END, otherRange) <= 0
return startsWithin && endsWithin
})
return !isContained && !range.collapsed
})
if(key === "value") {
this.#value = finalValue
}
else {
this.#solution = finalValue
}
const highlight = WebwriterMark[key === "value"? "highlightValue": "highlightSolution"]
for(const range of prev) {
highlight.delete(range)
}
for(const range of value) {
highlight.add(range)
}
}
observer: MutationObserver
segments: Intl.SegmentData[] = []
connectedCallback(): void {
super.connectedCallback()
const segmenter = new Intl.Segmenter(undefined, {granularity: "word"})
this.segments = [...segmenter.segment(this.textContent)]
this.observer = new MutationObserver(() => {
this.value = this.value
this.segments = [...segmenter.segment(this.textContent)]
})
this.observer.observe(this, {characterData: true, childList: true, subtree: true})
}
disconnectedCallback(): void {
super.disconnectedCallback()
this.observer?.disconnect()
}
@eventOptions({passive: true})
@action({label: {_: "Toggle Highlight"}})
handleHighlight(e?: PointerEvent) {
if(e && this.isContentEditable && e.type === "click") {
return
}
else if(e && !this.isContentEditable && e.type === "contextmenu") {
return
}
// convert click to caret position
const {textNode, offset} = e
? getCaretPositionFromPoint(e)
: {textNode: document.getSelection().anchorNode, offset: document.getSelection().anchorOffset}
// convert caret position to segment range
const segment = this.segments.filter(seg => seg.isWordLike).find(({index, segment}) => index <= offset && offset <= index + segment.length)
// add or remove segment range from highlights
if(segment) {
const range = new SerializableRange()
const start = segment.index
const end = segment.index + segment.segment.length
range.setStart(textNode, start)
range.setEnd(textNode, end)
const key = this.isContentEditable? "solution": "value"
const sameRange = this[key].find(r => r.startContainer === textNode && r.endContainer === textNode && r.startOffset === start && r.endOffset == end)
if(sameRange) {
this[key] = this[key].filter(r => r !== sameRange)
}
else {
this[key] = [...this[key], range]
}
this.dispatchEvent(new CustomEvent("ww-answer-change", {
bubbles: true,
composed: true
}))
}
}
reset() {
this.value = this.solution = []
}
reportSolution() {}
render() {
return html`
`
}
}