import {classMap} from "lit/directives/class-map.js"; import {deepQuerySelectorAll} from "../../utilities/query"; import {HasSlotController} from "../../internal/slot"; import {html, unsafeCSS} from 'lit'; import {htmlToText, looksLikeHtml} from './html-text'; import {MutationController} from '@lit-labs/observers/mutation-controller.js'; import {property, queryAssignedNodes, queryAsync, state} from 'lit/decorators.js'; import {sanitizeHtml} from '../../utilities/sanitize-html'; import {unsafeHTML} from 'lit/directives/unsafe-html.js'; import ZincElement from "../../internal/zinc-element"; import type {PropertyValues} from 'lit'; import type ZnTile from "../tile"; import styles from './content-block.scss'; // What the editor (Quill) can produce beyond the sanitizer's defaults const EDITOR_TAGS = ['s', 'code', 'pre', 'blockquote', 'hr', 'span']; // The iframe can't see the shadow styles, so editor formatting is restyled to match the text view const IFRAME_STYLES = 'html,body{background-color:#fff;margin:0}img{max-width:100%;max-height:500px}' + 'p{margin:0}ul,ol{margin:0;padding-left:1.5em}' + 'blockquote{border-left:4px solid #ddd;margin:5px 0;padding-left:16px}' + 'code{background-color:#f0f0f0;border-radius:3px;font-size:85%;padding:2px 4px}' + 'pre{background-color:#23241f;border-radius:3px;margin:5px 0;padding:5px 10px;color:#f8f8f2;overflow:auto}' + 'pre code{background:none;padding:0}' + 'hr{border:none;border-top:1px solid #ddd;margin:8px 0}' + 'li[data-list=checked],li[data-list=unchecked]{list-style:none}' + 'li[data-list=checked]::before{content:"☑";color:#777;margin:0 .3em 0 -1.5em}' + 'li[data-list=unchecked]::before{content:"☐";color:#777;margin:0 .3em 0 -1.5em}'; interface TextRow { lines: string[]; type: 'reply' | 'text'; /** `lines` holds a single sanitized HTML string. */ html?: boolean; } /** * @summary Short summary of the component's intended use. * @documentation https://zinc.style/components/content-block * @status experimental * @since 1.0 * * @slot attachments - Attachments displayed at the bottom of the content when the block is * expanded. Use `zn-chat-message-attachment`, which auto-assigns itself to this slot. * * @csspart attachments - The attachments row at the bottom of the content. * @csspart header-attachments - The attachment count indicator in the header. */ export default class ContentBlock extends ZincElement { static styles = unsafeCSS(styles); @property() time = ''; @property() sender = ''; @property() avatar = ''; @property({type: Boolean, reflect: true}) outbound = false; @property({type: Boolean, attribute: "no-collapse"}) noCollapse = false; @property({type: Boolean, reflect: true}) short = false; @property({attribute: 'default-display', reflect: true}) defaultDisplay: 'text' | 'html' = 'text'; @queryAssignedNodes({slot: 'html', flatten: true}) htmlNodes!: Node[]; @queryAsync('iframe') iframe!: Promise; private readonly hasSlotController = new HasSlotController(this, 'text', 'html'); @state() private attachmentCount = 0; private _textRows: TextRow[] = []; private readonly _footerObserver = new MutationController(this, { target: null, config: {subtree: true, childList: true, attributes: true, attributeFilter: ['href', 'download', 'caption']}, callback: () => this._debouncedReplace(), }); private _replaceDebounce: number = 0; connectedCallback() { super.connectedCallback(); this.iframe.then((iframe) => { if (iframe.contentDocument) { const div = this.htmlNodes[0] as HTMLDivElement; if (div) { // Escaped source has no element children; textContent decodes it once, including &nbsp; let convertedHtml = div.children.length ? div.innerHTML : div.textContent ?? ''; convertedHtml = convertedHtml.replace(/)<[^<]*)*<\/script>/gi, ''); const {fontFamily, fontSize, lineHeight, color} = getComputedStyle(this.shadowRoot?.querySelector('.text-section') ?? this); iframe.srcdoc = `` + convertedHtml; } } iframe.addEventListener("load", () => { setTimeout(() => this._resizeIframe(iframe), 50); }); }); } disconnectedCallback() { if (this._replaceDebounce) { clearTimeout(this._replaceDebounce); this._replaceDebounce = 0; } super.disconnectedCallback(); } private _collapseContent(e: Event) { if (this.noCollapse) { return; } const fromActions = e.composedPath().some((el) => el instanceof HTMLElement && el.classList.contains('content-block-header__actions')); if (fromActions) { return; } this.short = !this.short; this.requestUpdate(); this.updateComplete.then(() => { if (!this.short) { this.iframe.then((iframe) => { if (iframe && !iframe.classList.contains('hidden')) { this._resizeIframe(iframe); } }); } }); } private _toggleText() { const textContent = this.shadowRoot?.querySelector('.text-content') as HTMLDivElement | null; this.iframe.then((iframe) => { if (textContent) { iframe.classList.add('hidden'); textContent.classList.remove('hidden'); } }); } private _toggleHtml() { const textContent = this.shadowRoot?.querySelector('.text-content') as HTMLDivElement | null; this.iframe.then((iframe) => { if (textContent) { iframe.classList.remove('hidden'); textContent.classList.add('hidden'); setTimeout(() => this._resizeIframe(iframe), 0); } }); } private _resizeIframe(iframe?: HTMLIFrameElement) { const resize = (frame: HTMLIFrameElement) => { const doc = frame.contentDocument; if (!doc) return; // Allow the iframe to shrink as well as grow frame.style.height = 'auto'; const body = doc.body; const element = doc.documentElement; const heights = [ body?.scrollHeight ?? 0, element?.scrollHeight ?? 0, body?.offsetHeight ?? 0, element?.offsetHeight ?? 0 ]; const height = Math.max(...heights); if (height > 0) { frame.style.height = `${height}px`; } }; if (iframe) { resize(iframe); } else { this.iframe.then((frame) => { if (frame?.contentDocument) { requestAnimationFrame(() => resize(frame)); } }); } } private handleAttachmentsSlotChange() { this.syncAttachmentCount(); } private syncAttachmentCount() { const slot = this.shadowRoot?.querySelector('slot[name="attachments"]'); this.attachmentCount = slot?.assignedElements().length ?? 0; } protected firstUpdated(_changedProperties: PropertyValues) { super.firstUpdated(_changedProperties); this.syncAttachmentCount(); Promise.resolve().then(() => this.syncAttachmentCount()); const textContent = this.shadowRoot?.querySelectorAll('.text-section'); if (textContent && textContent.length > 0) { textContent.forEach((content) => { content.innerHTML = content.innerHTML.replace(/ /g, ' '); content.innerHTML = content.innerHTML.replace(//g, ''); content.innerHTML = content.innerHTML.replace(/<\/p>
/g, '

'); }); } } protected render() { const text = this.getTextSections(); const hasFooter = this.querySelector(`:scope > [slot="footer"]`) !== null; const hasTextSlot = this.hasSlotController.test('text'); const hasHtmlSlot = this.hasSlotController.test('html'); const showActions = hasTextSlot && hasHtmlSlot; const initialShowHtml = hasHtmlSlot && (!hasTextSlot || this.defaultDisplay === 'html'); return html`
${this.sender} ${this.truncateText()} ${this.attachmentCount > 0 ? html` ${this.attachmentCount} ` : ''}
${this.time} ${showActions ? html` Show HTML Show Text ` : ''}
${text.map((section) => html` ${section.type === 'reply' ? html`
...
` : ''}
${section.html ? unsafeHTML(section.lines[0]) : section.lines.map((line) => html`${line}
`)}
`)}
${hasFooter ? html` ` : ''}
`; } protected truncateText() { const textContent = this.querySelector('[slot="text"]') as HTMLDivElement | null; if (!textContent) return ''; const trimmed = htmlToText(textContent.innerText); return trimmed.length > 32 ? trimmed.substring(0, 32) + '...' : trimmed; } private _handleSlotChange(e: Event) { const slot = e.target as HTMLSlotElement; const assignedEls: Element[] = slot.assignedElements({flatten: true}); if (!assignedEls || assignedEls.length === 0) return; assignedEls.forEach(el => this._footerObserver.observe(el)); // Run now and again shortly after to catch async rendering this._replaceImagePlaceholders(); setTimeout(() => this._replaceImagePlaceholders(), 50); } private _debouncedReplace() { if (this._replaceDebounce) { clearTimeout(this._replaceDebounce); } this._replaceDebounce = window.setTimeout(() => this._replaceImagePlaceholders(), 50); } private _replaceImagePlaceholders() { const textContainer = this.shadowRoot?.querySelector('.text-content') as HTMLDivElement | null; if (!textContainer) { // If the shadow content hasn't been rendered yet, try after the next update this.updateComplete.then(() => this._replaceImagePlaceholders()); return; } const footer = this.querySelector(':scope > [slot="footer"]') as HTMLElement | null; if (!footer) return; const anchors = deepQuerySelectorAll('a[href]', footer, '') as HTMLAnchorElement[]; if (anchors.length === 0) return; let textContent = textContainer.innerHTML; const extensions = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'heic', 'tiff', 'tif', 'avif']); anchors.forEach((a) => { const candidates = new Set(); const tile = a.querySelector('zn-tile') as ZnTile | null; const caption = (tile?.caption ?? tile?.getAttribute?.('caption') ?? '').trim(); if (caption) candidates.add(caption); const downloadAttr = (a.getAttribute('download') || '').trim(); if (downloadAttr) candidates.add(downloadAttr); const url = a.getAttribute('href') || ''; candidates.forEach((rawName) => { const name = (rawName || '').trim(); if (!name) return; const base = name.includes('/') ? name.split('/').pop()! : name; const lowerBase = base.toLowerCase(); const ext = lowerBase.includes('.') ? lowerBase.split('.').pop()! : ''; if (ext && !extensions.has(ext)) return; const alt = base.replace(/"/g, '"'); textContent = textContent.replace(`[image: ${base}]`, () => `${alt}`); }); }); // Update the generated content directly without triggering a re-render textContainer.innerHTML = textContent; } protected getTextSections(): TextRow[] { const textContent = this.querySelector('[slot="text"]') as HTMLDivElement | null; const textRows: TextRow[] = []; if (textContent && looksLikeHtml(textContent.innerText)) { this._textRows = [{lines: [sanitizeHtml(textContent.innerText, EDITOR_TAGS, true)], type: 'text', html: true}]; return this._textRows; } if (textContent) { const text = textContent.innerText.replace(//gi, '\n'); const rows = text.split('\n'); let previousType: TextRow['type'] | null = null; let forceReply = false; let type: TextRow['type'] = 'text'; const containsGraterThan = rows.some((row) => row.startsWith('>')); rows.forEach((rowOrig) => { let row = rowOrig; if (((row.startsWith('On') && row.endsWith('wrote:')) || row.startsWith('From:')) && !containsGraterThan) forceReply = true; if (row.startsWith('--')) forceReply = false; type = (forceReply || row.startsWith('>')) ? 'reply' : 'text'; if (row.startsWith('Sent from')) type = 'reply'; if (row.trim() === '' && previousType === 'reply') type = 'reply'; if (type === 'reply' && row.startsWith('>')) row = row.substring(1).trim(); if (previousType === type) { if (textRows.length === 0) { textRows.push({lines: [row], type}); previousType = type; return; } textRows[textRows.length - 1].lines.push(row); } else { textRows.push({lines: [row], type}); previousType = type; } }); } this._textRows = textRows; return this._textRows; } showReply(e: MouseEvent) { const target = e.target as HTMLElement; const nextElement = target.nextElementSibling; if (nextElement) { nextElement.classList.toggle('hidden'); } } }