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` ` } }