From 86ace28066dff48328e18cec8e62fe6f3d324f20 Mon Sep 17 00:00:00 2001 From: CrescentLeaf Date: Sun, 9 Nov 2025 16:06:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=8C=E6=96=87=E6=9C=AC=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=A4=A7=E9=87=8D=E6=9E=84=EF=BC=81=EF=BC=81?= =?UTF-8?q?=EF=BC=81=20*=20=E5=B0=86=E6=89=80=E6=9C=89=E7=9A=84=20custom?= =?UTF-8?q?=20element=20=E4=BB=A5=E6=AD=A3=E7=A1=AE=E7=9A=84=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E9=87=8D=E6=96=B0=E7=BC=96=E5=86=99=20*=20=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E6=AD=A3=E7=A1=AE=E8=A7=A3=E6=9E=90=20Markdown=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC,=20=E5=9B=BE=E7=89=87,=20=E6=96=9C=E4=BD=93?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=85=83=E7=B4=A0=E4=B8=94=E4=B8=8D=E4=BC=9A?= =?UTF-8?q?=E6=9D=82=E7=B3=85=E4=BA=86=20*=20=E9=80=9A=E8=BF=87=20DOM=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=BD=BF=E5=BE=97=E6=89=80=E6=9C=89=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=81=9A=E5=90=88=E5=9C=A8=E4=B8=80=E8=B5=B7?= =?UTF-8?q?,=20=E5=B9=B6=E4=B8=94=E5=8F=96=E6=B6=88=E4=BA=86=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=87=AA=E5=B8=A6=E7=9A=84=E5=A1=AB=E5=85=85=E8=BE=B9?= =?UTF-8?q?=E8=B7=9D,=20=E5=88=A0=E9=99=A4=E4=BA=86=E5=8E=9F=E6=9C=AC?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=86=85=E6=97=A0=E6=B3=95=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E7=9A=84=20"=E6=97=A0=E8=BE=B9=E6=A1=86?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=A8=A1=E5=BC=8F"=20*=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=96=B0=E7=9A=84=20custom-element:=20chat-text=20=E5=92=8C=20?= =?UTF-8?q?chat-text-container?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/index.ts | 2 + client/ui/chat/ChatFragment.tsx | 54 +++++++++++-------- client/ui/chat/Message.tsx | 47 +++++++++++++--- .../ui/custom-elements/chat-text-container.ts | 16 ++++++ client/ui/custom-elements/chat-text.ts | 29 ++++++++++ 5 files changed, 118 insertions(+), 30 deletions(-) create mode 100644 client/ui/custom-elements/chat-text-container.ts create mode 100644 client/ui/custom-elements/chat-text.ts diff --git a/client/index.ts b/client/index.ts index 752bce3..0ca8208 100644 --- a/client/index.ts +++ b/client/index.ts @@ -9,6 +9,8 @@ import ReactDOM from 'react-dom/client' import './ui/custom-elements/chat-image.ts' import './ui/custom-elements/chat-video.ts' import './ui/custom-elements/chat-file.ts' +import './ui/custom-elements/chat-text.ts' +import './ui/custom-elements/chat-text-container.ts' import App from './ui/App.tsx' import AppMobile from './ui/AppMobile.tsx' diff --git a/client/ui/chat/ChatFragment.tsx b/client/ui/chat/ChatFragment.tsx index 827e634..d13c85d 100644 --- a/client/ui/chat/ChatFragment.tsx +++ b/client/ui/chat/ChatFragment.tsx @@ -29,6 +29,7 @@ import PreferenceUpdater from "../preference/PreferenceUpdater.ts" import SystemMessage from "./SystemMessage.tsx" import JoinRequestsList from "./JoinRequestsList.tsx" import getUrlForFileByHash from "../../getUrlForFileByHash.ts" +import escapeHTML from "../../escapeHtml.ts" interface Args extends React.HTMLAttributes { target: string @@ -38,24 +39,47 @@ interface Args extends React.HTMLAttributes { openUserInfoDialog: (user: User | string) => void } +const sanitizeConfig = { + ALLOWED_TAGS: [ + "chat-image", + "chat-video", + "chat-file", + 'chat-text', + "chat-link", + ], + ALLOWED_ATTR: [ + 'underline', + 'em', + + 'src', + 'alt', + 'href', + 'name', + ], +} + const markedInstance = new marked.Marked({ renderer: { + text({ text }) { + console.log('普通文字', text) + return `${escapeHTML(text)}` + }, + em({ text }) { + console.log('斜体', text) + return `${escapeHTML(text)}` + }, heading({ tokens, depth: _depth }) { const text = this.parser.parseInline(tokens) - return `${text}` - }, - paragraph({ tokens }) { - const text = this.parser.parseInline(tokens) - return `${text}` + return `${escapeHTML(text)}` }, image({ text, href }) { const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image' if (/tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) { const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]) return ({ - Image: ``, + Image: ``, Video: ``, - File: ``, + File: ``, })?.[type] || `` } return `` @@ -377,21 +401,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC (() => { let date = new Date(0) return messagesList.map((msg) => { - const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, { - ALLOWED_TAGS: [ - "chat-image", - "chat-video", - "chat-file", - "span", - "chat-link", - ], - ALLOWED_ATTR: [ - 'src', - 'alt', - 'href', - 'name', - ], - }).replaceAll('\n', '
') + const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig) const lastDate = date date = new Date(msg.time) diff --git a/client/ui/chat/Message.tsx b/client/ui/chat/Message.tsx index e6d3306..8100b7d 100644 --- a/client/ui/chat/Message.tsx +++ b/client/ui/chat/Message.tsx @@ -21,6 +21,34 @@ interface Args extends React.HTMLAttributes { openUserInfoDialog: (user: User | string) => void } +function prettyFlatParsedMessage(html: string) { + const elements = new DOMParser().parseFromString(html, 'text/html').body.children + let ls: Element[] = [] + let ret = '' + // 第一个元素时, 不会被聚合在一起 + let lastElementType = '' + function checkContinuousElement(tagName: string) { + if (lastElementType != tagName) { + if (lastElementType == 'chat-text') + ret += `${ls.map((v) => v.outerHTML).join('')}` + else + ret += ls.map((v) => v.outerHTML).join('') + ls = [] + } + } + for (const e of elements) { + console.log(e) + // 当出现非文本元素时, 将文本聚合在一起 + // 如果是其他类型, 虽然也执行聚合, 但是不会有外层包裹 + checkContinuousElement(e.nodeName.toLowerCase()) + ls.push(e) + lastElementType = e.nodeName.toLowerCase() + } + // 最后将剩余的转换 + checkContinuousElement('') + return ret +} + export default function Message({ userId, rawData, renderHTML, message, openUserInfoDialog, ...props }: Args) { const isAtRight = Client.myUserProfile?.id == userId @@ -44,15 +72,15 @@ export default function Message({ userId, rawData, renderHTML, message, openUser const [isDropDownOpen, setDropDownOpen] = React.useState(false) - const [isUsingFullDisplay, setIsUsingFullDisplay] = React.useState(false) + /* const [isUsingFullDisplay, setIsUsingFullDisplay] = React.useState(false) */ - React.useEffect(() => { + /* React.useEffect(() => { const text = $(dropDownRef.current as HTMLElement).find('#msg').text().trim() setIsUsingFullDisplay(text == '' || ( rawData.split("tws:\/\/file\?hash=").length == 2 && /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim()) )) - }, [renderHTML]) + }, [renderHTML]) */ return (
{ @@ -138,10 +167,12 @@ export default function Message({ userId, rawData, renderHTML, message, openUser slot="trigger" id="msg" style={{ - fontSize: "94%" + fontSize: "94%", + display: 'flex', + flexDirection: 'column', }} dangerouslySetInnerHTML={{ - __html: renderHTML + __html: prettyFlatParsedMessage(renderHTML) }} /> { e.stopPropagation() diff --git a/client/ui/custom-elements/chat-text-container.ts b/client/ui/custom-elements/chat-text-container.ts new file mode 100644 index 0000000..05ce0a9 --- /dev/null +++ b/client/ui/custom-elements/chat-text-container.ts @@ -0,0 +1,16 @@ +customElements.define('chat-text-container', class extends HTMLElement { + declare container: HTMLDivElement + constructor() { + super() + + this.attachShadow({ mode: 'open' }) + } + connectedCallback() { + const shadow = this.shadowRoot as ShadowRoot + + this.container = document.createElement('div') + this.container.style.padding = '13px' + shadow.appendChild(this.container) + this.container.innerHTML = this.innerHTML + } +}) diff --git a/client/ui/custom-elements/chat-text.ts b/client/ui/custom-elements/chat-text.ts new file mode 100644 index 0000000..9f24610 --- /dev/null +++ b/client/ui/custom-elements/chat-text.ts @@ -0,0 +1,29 @@ +import { $ } from 'mdui' + +customElements.define('chat-text', class extends HTMLElement { + declare span: HTMLSpanElement + static observedAttributes = ['underline', 'em'] + constructor() { + super() + + this.attachShadow({ mode: 'open' }) + } + connectedCallback() { + const shadow = this.shadowRoot as ShadowRoot + + this.span = document.createElement('span') + this.span.style.whiteSpace = 'pre-wrap' + this.span.style.fontSynthesis = 'style weight' + shadow.appendChild(this.span) + + this.update() + } + attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) { + this.update() + } + update() { + this.span.textContent = this.textContent + this.span.style.textDecoration = $(this).attr('underline') ? 'underline' : '' + this.span.style.fontStyle = $(this).attr('em') ? 'italic' : '' + } +})