Compare commits

...

6 Commits

Author SHA1 Message Date
CrescentLeaf
86ace28066 富文本消息显示大重构!!!
* 将所有的 custom element 以正确的方式重新编写
* 可以正确解析 Markdown 文本, 图片, 斜体文本元素且不会杂糅了
* 通过 DOM 操作使得所有的文本聚合在一起, 并且取消了消息自带的填充边距, 删除了原本消息内无法正常工作的 "无边框显示模式"
* 添加新的 custom-element: chat-text 和 chat-text-container
2025-11-09 16:06:24 +08:00
CrescentLeaf
b46449a6e4 fix: chat-video 没有更新 2025-11-09 16:03:29 +08:00
CrescentLeaf
19b2fce904 util: escapeHtml 2025-11-09 16:01:53 +08:00
CrescentLeaf
a7df2c689a ui: 移除对媒体文件的显示圆角, 并修正大小 (块级元素) 2025-11-09 16:01:38 +08:00
CrescentLeaf
6ce8acdb2e build: 取消丢弃 console 2025-11-09 12:46:38 +08:00
CrescentLeaf
149f003175 fix: typo 2025-11-09 10:39:06 +08:00
11 changed files with 192 additions and 72 deletions

View File

@@ -137,7 +137,7 @@ export default class LingChairClient {
password: string, password: string,
}) { }) {
try { try {
this.registerOrThrow(args) await this.registerOrThrow(args)
return true return true
} catch (_) { } catch (_) {
return false return false

5
client/escapeHtml.ts Normal file
View File

@@ -0,0 +1,5 @@
export default function escapeHTML(str: string) {
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}

View File

@@ -9,6 +9,8 @@ import ReactDOM from 'react-dom/client'
import './ui/custom-elements/chat-image.ts' import './ui/custom-elements/chat-image.ts'
import './ui/custom-elements/chat-video.ts' import './ui/custom-elements/chat-video.ts'
import './ui/custom-elements/chat-file.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 App from './ui/App.tsx'
import AppMobile from './ui/AppMobile.tsx' import AppMobile from './ui/AppMobile.tsx'

View File

@@ -29,6 +29,7 @@ import PreferenceUpdater from "../preference/PreferenceUpdater.ts"
import SystemMessage from "./SystemMessage.tsx" import SystemMessage from "./SystemMessage.tsx"
import JoinRequestsList from "./JoinRequestsList.tsx" import JoinRequestsList from "./JoinRequestsList.tsx"
import getUrlForFileByHash from "../../getUrlForFileByHash.ts" import getUrlForFileByHash from "../../getUrlForFileByHash.ts"
import escapeHTML from "../../escapeHtml.ts"
interface Args extends React.HTMLAttributes<HTMLElement> { interface Args extends React.HTMLAttributes<HTMLElement> {
target: string target: string
@@ -38,24 +39,47 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
openUserInfoDialog: (user: User | string) => void 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({ const markedInstance = new marked.Marked({
renderer: { 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 }) { heading({ tokens, depth: _depth }) {
const text = this.parser.parseInline(tokens) const text = this.parser.parseInline(tokens)
return `<span>${text}</span>` return `<chat-text>${escapeHTML(text)}</chat-text>`
},
paragraph({ tokens }) {
const text = this.parser.parseInline(tokens)
return `<span>${text}</span>`
}, },
image({ text, href }) { image({ text, href }) {
const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image' const type = /^(Video|File)=.*/.exec(text)?.[1] || 'Image'
if (/tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) { if (/tws:\/\/file\?hash=[A-Za-z0-9]+$/.test(href)) {
const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1]) const url = getUrlForFileByHash(/^tws:\/\/file\?hash=(.*)/.exec(href)?.[1])
return ({ 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>`, 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] || `` })?.[type] || ``
} }
return `` return ``
@@ -377,21 +401,7 @@ export default function ChatFragment({ target, showReturnButton, onReturnButtonC
(() => { (() => {
let date = new Date(0) let date = new Date(0)
return messagesList.map((msg) => { return messagesList.map((msg) => {
const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, { const rendeText = DOMPurify.sanitize(markedInstance.parse(msg.text) as string, sanitizeConfig)
ALLOWED_TAGS: [
"chat-image",
"chat-video",
"chat-file",
"span",
"chat-link",
],
ALLOWED_ATTR: [
'src',
'alt',
'href',
'name',
],
}).replaceAll('\n', '<br>')
const lastDate = date const lastDate = date
date = new Date(msg.time) date = new Date(msg.time)

View File

@@ -21,6 +21,34 @@ interface Args extends React.HTMLAttributes<HTMLElement> {
openUserInfoDialog: (user: User | string) => void 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) { export default function Message({ userId, rawData, renderHTML, message, openUserInfoDialog, ...props }: Args) {
const isAtRight = Client.myUserProfile?.id == userId 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 [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() const text = $(dropDownRef.current as HTMLElement).find('#msg').text().trim()
setIsUsingFullDisplay(text == '' || ( setIsUsingFullDisplay(text == '' || (
rawData.split("tws:\/\/file\?hash=").length == 2 rawData.split("tws:\/\/file\?hash=").length == 2
&& /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim()) && /\<\/chat\-(file|image|video)\>(\<\/span\>)?$/.test(renderHTML.trim())
)) ))
}, [renderHTML]) }, [renderHTML]) */
return ( return (
<div <div
@@ -122,10 +150,11 @@ export default function Message({ userId, rawData, renderHTML, message, openUser
minWidth: "0%", minWidth: "0%",
[isAtRight ? "marginRight" : "marginLeft"]: "55px", [isAtRight ? "marginRight" : "marginLeft"]: "55px",
marginTop: "-5px", marginTop: "-5px",
padding: isUsingFullDisplay ? undefined : "13px",
paddingTop: isUsingFullDisplay ? undefined : "14px",
alignSelf: isAtRight ? "flex-end" : "flex-start", 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}> <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" slot="trigger"
id="msg" id="msg"
style={{ style={{
fontSize: "94%" fontSize: "94%",
display: 'flex',
flexDirection: 'column',
}} }}
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: renderHTML __html: prettyFlatParsedMessage(renderHTML)
}} /> }} />
<mdui-menu onClick={(e) => { <mdui-menu onClick={(e) => {
e.stopPropagation() e.stopPropagation()

View File

@@ -7,7 +7,7 @@ customElements.define('chat-file', class extends HTMLElement {
connectedCallback() { connectedCallback() {
const e = new DOMParser().parseFromString(` const e = new DOMParser().parseFromString(`
<a style="width: 100%;height: 100%;"> <a style="width: 100%;height: 100%;">
<mdui-card clickable style="display: flex;align-items: center;"> <mdui-card clickable style="display: flex;align-items: center;box-shadow: inherit;border-radius: inherit;">
<mdui-icon name="insert_drive_file" style="margin: 13px;font-size: 34px;"></mdui-icon> <mdui-icon name="insert_drive_file" style="margin: 13px;font-size: 34px;"></mdui-icon>
<span style="margin-right: 13px; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span> <span style="margin-right: 13px; word-wrap: break-word; word-break:break-all;white-space:normal; max-width :100%;"></span>
</mdui-card> </mdui-card>

View File

@@ -1,39 +1,59 @@
import openImageViewer from "../openImageViewer.ts" import openImageViewer from "../openImageViewer.ts"
import { snackbar } from "../snackbar.ts"
import { $ } from 'mdui/jq' import { $ } from 'mdui/jq'
customElements.define('chat-image', class extends HTMLElement { customElements.define('chat-image', class extends HTMLElement {
static observedAttributes = ['src', 'show-error']
declare img: HTMLImageElement
declare error: HTMLElement
constructor() { constructor() {
super() super()
this.attachShadow({ mode: 'open' })
}
update() {
if (this.img == null) return
this.img.src = $(this).attr('src') as string
const error = $(this).attr('show-error') == 'true'
this.img.style.display = error ? 'none' : 'block'
this.error.style.display = error ? '' : 'none'
}
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
this.update()
} }
connectedCallback() { connectedCallback() {
this.style.display = 'block' this.img = new Image()
const e = new Image() this.img.style.width = '100%'
e.style.maxWidth = "400px" this.img.style.maxHeight = "300px"
e.style.maxHeight = "300px" this.img.style.objectFit = 'cover'
e.style.marginTop = '5px' // this.img.style.borderRadius = "var(--mdui-shape-corner-medium)"
e.style.marginBottom = '5px' this.shadowRoot!.appendChild(this.img)
e.style.borderRadius = "var(--mdui-shape-corner-medium)"
e.alt = $(this).attr('alt') || "" this.error = new DOMParser().parseFromString(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`, 'text/html').body.firstChild as HTMLElement
e.onerror = () => { this.shadowRoot!.appendChild(this.error)
const src = $(this).attr('src')
$(this).html(`<mdui-icon name="broken_image" style="font-size: 2rem;"></mdui-icon>`) this.img.addEventListener('error', () => {
$(this).attr('alt', '无法加载: ' + $(this).attr('alt')) $(this).attr('show-error', 'true')
$(this).on('click', () => { })
snackbar({ this.error.addEventListener('click', (event) => {
message: `图片 (${src}) 无法加载!`, event.stopPropagation()
placement: 'top' const img = this.img
}) this.img = new Image()
}) this.img.style.width = '100%'
} this.img.style.maxHeight = "300px"
e.src = $(this).attr('src') as string this.img.style.objectFit = 'cover'
e.onclick = (event) => { this.shadowRoot!.replaceChild(img, this.img)
$(this).attr('show-error', undefined)
})
this.img.addEventListener('click', (event) => {
event.stopPropagation() event.stopPropagation()
openImageViewer($(this).attr('src') as string) openImageViewer($(this).attr('src') as string)
} })
this.appendChild(e)
this.update()
} }
}) })

View 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
}
})

View 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' : ''
}
})

View File

@@ -1,19 +1,32 @@
import { $ } from 'mdui/jq' import { $ } from 'mdui/jq'
customElements.define('chat-video', class extends HTMLElement { customElements.define('chat-video', class extends HTMLElement {
static observedAttributes = ['src']
declare video: HTMLVideoElement
constructor() { constructor() {
super() super()
this.attachShadow({ mode: 'open' })
}
update() {
if (this.video == null) return
this.video.src = $(this).attr('src') as string
}
attributeChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown) {
this.update()
} }
connectedCallback() { connectedCallback() {
this.style.display = 'block' this.video = new DOMParser().parseFromString(`<video controls></video>`, 'text/html').body.firstChild as HTMLVideoElement
const e = new DOMParser().parseFromString(`<video controls></video>`, 'text/html').body.firstChild as HTMLVideoElement this.video.style.maxWidth = "400px"
e.style.maxWidth = "400px" this.video.style.maxHeight = "300px"
e.style.maxHeight = "300px" this.video.style.width = "100%"
e.style.width = "100%" this.video.style.height = "100%"
e.style.height = "100%" this.video.style.display = 'block'
e.style.borderRadius = "var(--mdui-shape-corner-medium)" // e.style.borderRadius = "var(--mdui-shape-corner-medium)"
e.src = $(this).attr('src') as string
e.onclick = (e) => e.stopPropagation() this.video.onclick = (e) => e.stopPropagation()
this.appendChild(e) this.shadowRoot!.appendChild(this.video)
this.update()
} }
}) })

View File

@@ -11,12 +11,6 @@ export default defineConfig({
outDir: "." + config.data_path + '/page_compiled', outDir: "." + config.data_path + '/page_compiled',
minify: 'terser', minify: 'terser',
cssMinify: 'lightningcss', cssMinify: 'lightningcss',
terserOptions:{
compress:{
drop_console:true,
drop_debugger:true
}
},
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks(id) { manualChunks(id) {