富文本消息显示大重构!!!
* 将所有的 custom element 以正确的方式重新编写 * 可以正确解析 Markdown 文本, 图片, 斜体文本元素且不会杂糅了 * 通过 DOM 操作使得所有的文本聚合在一起, 并且取消了消息自带的填充边距, 删除了原本消息内无法正常工作的 "无边框显示模式" * 添加新的 custom-element: chat-text 和 chat-text-container
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
target: string
|
||||
@@ -38,24 +39,47 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
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 `<chat-text>${escapeHTML(text)}</chat-text>`
|
||||
},
|
||||
em({ text }) {
|
||||
console.log('斜体', text)
|
||||
return `<chat-text em="true">${escapeHTML(text)}</chat-text>`
|
||||
},
|
||||
heading({ tokens, depth: _depth }) {
|
||||
const text = this.parser.parseInline(tokens)
|
||||
return `<span>${text}</span>`
|
||||
},
|
||||
paragraph({ tokens }) {
|
||||
const text = this.parser.parseInline(tokens)
|
||||
return `<span>${text}</span>`
|
||||
return `<chat-text>${escapeHTML(text)}</chat-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: `<chat-image src="${url}" alt="${text}"></chat-image>`,
|
||||
Image: `<chat-image src="${url}" alt="${escapeHTML(text)}"></chat-image>`,
|
||||
Video: `<chat-video src="${url}"></chat-video>`,
|
||||
File: `<chat-file href="${url}" name="${/^Video|File=(.*)/.exec(text)?.[1] || 'Unnamed file'}"></chat-file>`,
|
||||
File: `<chat-file href="${url}" name="${escapeHTML(/^Video|File=(.*)/.exec(text)?.[1] || 'Unnamed file')}"></chat-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', '<br>')
|
||||
const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig)
|
||||
const lastDate = date
|
||||
date = new Date(msg.time)
|
||||
|
||||
|
||||
@@ -21,6 +21,34 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
|
||||
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 += `<chat-text-container>${ls.map((v) => v.outerHTML).join('')}</chat-text-container>`
|
||||
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 (
|
||||
<div
|
||||
@@ -122,10 +150,11 @@ export default function Message({ userId, rawData, renderHTML, message, openUser
|
||||
minWidth: "0%",
|
||||
[isAtRight ? "marginRight" : "marginLeft"]: "55px",
|
||||
marginTop: "-5px",
|
||||
padding: isUsingFullDisplay ? undefined : "13px",
|
||||
paddingTop: isUsingFullDisplay ? undefined : "14px",
|
||||
alignSelf: isAtRight ? "flex-end" : "flex-start",
|
||||
backgroundColor: isUsingFullDisplay ? "inherit" : undefined
|
||||
// boxShadow: isUsingFullDisplay ? 'inherit' : 'var(--mdui-elevation-level1)',
|
||||
// padding: isUsingFullDisplay ? undefined : "13px",
|
||||
// paddingTop: isUsingFullDisplay ? undefined : "14px",
|
||||
// backgroundColor: isUsingFullDisplay ? "inherit" : undefined
|
||||
}}>
|
||||
<mdui-dialog close-on-overlay-click close-on-esc ref={messageJsonDialogRef}>
|
||||
{
|
||||
@@ -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)
|
||||
}} />
|
||||
<mdui-menu onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
16
client/ui/custom-elements/chat-text-container.ts
Normal file
16
client/ui/custom-elements/chat-text-container.ts
Normal file
@@ -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
|
||||
}
|
||||
})
|
||||
29
client/ui/custom-elements/chat-text.ts
Normal file
29
client/ui/custom-elements/chat-text.ts
Normal file
@@ -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' : ''
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user