Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
Access文本框中,如何設(shè)置提示文字,即文本框有水印提示錄入信息,當(dāng)文本框獲取到焦點(diǎn)時(shí),提示信息消失
如下效果圖
這里方法很多:
可以用獲得焦點(diǎn)和失去焦點(diǎn)的方法來(lái)處理,設(shè)置默認(rèn)文本為“請(qǐng)輸入用戶名”,前景色設(shè)為灰色。然后事件處理。
1、獲得焦點(diǎn)事件,判斷前景色是否為灰色,是的話,把前景色改成黑色,并把文本框清空。
2、失去焦點(diǎn)事件,判斷文本框的內(nèi)容是否為空,是的話,把前景色改成灰色,并加上“提示信息”文字
也可以通過(guò)條件格式來(lái)控制,用IFF來(lái)判斷是否為空,如果為空即顯示條件格式的設(shè)置的表達(dá)式。
這里我們介紹一個(gè)另類的方法:在文本框 格式屬性設(shè)置如下信息即可:
&;![藍(lán)色]"輸入省份"
&;!"輸入市"
&;![紅色]"輸入縣區(qū)"
詳細(xì)源碼:
http://www.office-cn.net/access-control/354.html
活中犯錯(cuò)誤是正常的,沒(méi)有人不會(huì)犯錯(cuò)誤,更何況是開發(fā)人員呢?今天我們就來(lái)卡看看開發(fā)人員在編寫 HTML 和 CSS 時(shí)最常犯的六大錯(cuò)誤有哪些。
作者 | Stas Melnikov
譯者 | 彎月,責(zé)編 | 劉靜
出品 | CSDN(ID:CSDNnews)
以下為譯文:
用placeholder屬性代替label元素
開發(fā)人員經(jīng)常用placeholder屬性代替label元素。但是,在這種寫法下,使用屏幕閱讀器的用戶無(wú)法填寫字段,因?yàn)槠聊婚喿x器無(wú)法從placeholder屬性中讀取文本。
<input type="email" placeholder="Enter your email">
因此,我建議用label元素顯示字段名稱,而placeholder應(yīng)該作為例子顯示在用戶需要填充的數(shù)據(jù)中。
<label>
<span>Enter your email</span>
<input type="email" placeholder="e.g. example@gmail.com">
</label>
用img元素標(biāo)記裝飾用的圖片
我經(jīng)常看到開發(fā)人員混淆裝飾圖片和內(nèi)容圖片。例如,他們會(huì)使用img元素來(lái)顯示社交圖標(biāo)。
<a href="https://twitter.com" class="social">
<img class="social__icon" src="twitter.svg" alt>
<span class="social__name">Twitter</span>
</a>
然而,社交圖標(biāo)是裝飾性圖標(biāo),其目的是幫助用戶迅速理解元素的含義,而無(wú)需閱讀文本。即便我們刪除這些圖標(biāo),元素的含義也不會(huì)消失,所以我們應(yīng)該使用background-image屬性。
<a href="https://twitter.com" class="social">
<span class="social__name">Twitter</span>
</a>
.social::before {
background-image: url("twitter.svg");
}
使用resize屬性
如果利用resize屬性來(lái)禁止textarea調(diào)整大小,那么你就破壞了可訪問(wèn)性。因?yàn)橛脩魺o(wú)法舒適地輸入數(shù)據(jù)。
textarea {
width: 100%;
height: 200px;
resize: none;
}
你應(yīng)該使用min-width、max-width、min-height以及max-height屬性,這些屬性可以限制元素的大小,而且用戶也可以舒舒服服地輸入數(shù)據(jù)。
textarea {
min-width: 100%;
max-width: 100%;
min-height: 200px;
max-height: 400px;
}
同時(shí)使用display: block和position: absolute(fixed)
我經(jīng)常看見開發(fā)人員像下面這樣使用display和position屬性:
.button::before {
content: "";
display: block;
position: absolute;
top: 0;
left: 0;
}
但是,瀏覽器會(huì)默認(rèn)設(shè)置block。因此,你無(wú)需為absolute或fixed的元素設(shè)置這個(gè)值。也就是說(shuō),以下代碼的結(jié)果與上述代碼完全相同。
.button::before {
content: "";
position: absolute;
top: 0;
left: 0;
}
Outline屬性的none值
無(wú)法通過(guò)鍵盤訪問(wèn)網(wǎng)站;鏈接打不開;無(wú)法注冊(cè)等等。出現(xiàn)這些情況是因?yàn)殚_發(fā)人員將outline屬性設(shè)置成了none值,因此元素?zé)o法聚焦。
.button:focus {
outline: none;
}
/* or */
.button:focus {
outline: 0;
}
如果你需要禁用默認(rèn)的聚焦,那么也別忘了指定取而代之的聚焦?fàn)顟B(tài)。
.button:focus {
outline: none;
box-shadow: 0 0 3px 0 blue;
}
空元素
開發(fā)人員經(jīng)常使用HTML空元素來(lái)調(diào)整元素的樣式。例如,利用空div或span元素來(lái)顯示導(dǎo)航欄菜單。
<button class="hamburger">
<span></span>
<span></span>
<span></span>
</button>
.hamburger {
width: 60px;
height: 45px;
position: relative;
}
.hamburger span {
width: 100%;
height: 9px;
background-color: #d3531a;
border-radius: 9px;
position: absolute;
left: 0;
}
.hamburger span:nth-child(1) {
top: 0;
}
.hamburger span:nth-child(2) {
top: 18px;
}
.hamburger span:nth-child(3) {
top: 36px;
}
其實(shí),你可以使用 ::before和 ::after偽元素達(dá)成同樣的效果。
<button class="hamburger">
<span class="hamburger__text">
<span class="visually-hidden">Open menu</span>
</span>
</button>
.hamburger {
width: 60px;
height: 45px;
position: relative;
}
.hamburger::before,
.hamburger::after,
.hamburger__text::before {
content: "";
width: 100%;
height: 9px;
background-color: #d3531a;
border-radius: 9px;
position: absolute;
left: 0;
}
.hamburger::before {
top: 0;
}
.hamburger::after {
top: 18px;
}
.hamburger__text::before {
top: 36px;
}
.visually-hidden {
position: absolute !important;
clip: rect(1px, 1px, 1px, 1px);
width: 1px !important;
height: 1px !important;
overflow: hidden;
}
原文:https://dev.to/melnik909/the-6-most-common-mistakes-developers-when-writing-html-and-css-f92
本文為 CSDN 翻譯,轉(zhuǎn)載請(qǐng)注明來(lái)源出處。
【END】
、背景
云文檔轉(zhuǎn)HTML郵件
基于公司內(nèi)部的飛書辦公套件,早在去年6月,我們就建設(shè)了將飛書云文檔轉(zhuǎn)譯成HTML郵件的能力,方便同學(xué)們?cè)诰帉戉]件文檔和發(fā)送郵件時(shí),都能有較好的體驗(yàn)和較高的效率。
當(dāng)下問(wèn)題
要被郵件客戶端識(shí)別,飛書云文檔內(nèi)容需要轉(zhuǎn)譯成HtmlEmail格式,該格式為了兼容各種版本的郵箱客戶端(特別是Windows Outlook),對(duì)于現(xiàn)代HTML5和CSS3的很多特性是不支持的,飛書云文檔的多種富文本塊格式都需要轉(zhuǎn)譯,且部分格式完全不支持,造成編輯和預(yù)覽發(fā)送不一致的情況。
因此,我們對(duì)轉(zhuǎn)譯工具做了一次大改版和升級(jí),對(duì)大部分常用文檔塊做了高度還原。
實(shí)現(xiàn)效果
經(jīng)過(guò)我們的不懈努力,最終實(shí)現(xiàn)了較為不錯(cuò)的還原效果:
二、系統(tǒng)架構(gòu)改版
飛書云文檔結(jié)構(gòu)
在展開我們?nèi)绾巫錾?jí)之前,先要簡(jiǎn)單了解下飛書云文檔的信息結(jié)構(gòu)(詳情可參考官方API),在此僅做簡(jiǎn)單闡述。
TypeScript簡(jiǎn)要定義,一個(gè)平鋪的文檔塊數(shù)組,根據(jù)block_id和parent_id確定各塊的父子關(guān)系,從而形成一個(gè)樹:
{
/** 文檔塊唯一標(biāo)識(shí)。*/
block_id: string;
/** 父塊 ID。*/
parent_id: string;
/** 子塊 ID 列表。*/
children: string[];
/** 文檔塊類型。*/
block_type: BlockType;
/** 頁(yè)面塊內(nèi)容描述。*/
page?: { ... };
/** 文本塊內(nèi)容描述。*/
text?: { ... };
/** 標(biāo)題 1 塊內(nèi)容描述。*/
heading1?: { ... };
/** 有序列表塊內(nèi)容描述。*/
ordered?: { ... };
/** 表格塊內(nèi)容描述。*/
table?: { ... };
// 總計(jì) 43 個(gè)塊定義。
...
}[];
我們用思維導(dǎo)圖簡(jiǎn)單舉例,整個(gè)文檔塊的樹結(jié)構(gòu)大致是這樣的,有些塊根據(jù)縮進(jìn)遞進(jìn),會(huì)形成父子關(guān)系,有些塊天然就會(huì)成為父塊(比如表格、引用等):
舊版架構(gòu)
那么我們初版轉(zhuǎn)譯工具是怎么做的呢,比較遺憾的是,由于當(dāng)時(shí)需求的還原度訴求較低,我們的代碼主要是復(fù)用現(xiàn)有部分實(shí)現(xiàn),整體的架構(gòu)設(shè)計(jì)可以用一個(gè)詞概括,基本是面向過(guò)程編程:
上方的圖:經(jīng)過(guò)了一些抽取和封裝,主流程核心代碼仍有528行;下方的圖:文檔塊核心轉(zhuǎn)譯渲染代碼,基本沒(méi)有寫任何還原樣式,通過(guò)Switch、Case來(lái)一個(gè)個(gè)渲染文檔塊。
新版架構(gòu)設(shè)計(jì)
這次我們痛定思痛,勢(shì)必要將轉(zhuǎn)譯工具的轉(zhuǎn)譯效果做到盡可能還原,也有了多位同學(xué)一起投入。因此首要思考和急需解決的問(wèn)題來(lái)了:在老舊的架構(gòu)下,如何才能做好代碼擴(kuò)展、多人協(xié)同、高效樣式編寫以及樣式還原?
IoC 與DI
是的,幾乎一剎那,憑借過(guò)往豐富的多人協(xié)同以及項(xiàng)目經(jīng)驗(yàn),很快我們就想到了,這個(gè)事需要基于IoC的設(shè)計(jì)原則,并通過(guò)DI的方式來(lái)實(shí)現(xiàn)。
那么什么是IoC和DI呢,根據(jù)維基百科的解釋:控制反轉(zhuǎn)(Inversion of Control,縮寫為IoC),是面向?qū)ο缶幊讨械囊环N設(shè)計(jì)原則,可以用來(lái)減低計(jì)算機(jī)代碼之間的耦合度,其中最常見的方式叫做依賴注入(Dependency Injection,縮寫為DI)。
這么說(shuō)可能有點(diǎn)抽象,我們可以看下新版的架構(gòu)設(shè)計(jì),從中便能窺見其精妙:
可以看到,關(guān)鍵的文檔塊預(yù)處理和渲染器,在該架構(gòu)中是反向依賴核心的createDocTranspiler了,與我們常識(shí)中的理解(文檔轉(zhuǎn)譯渲染依賴各個(gè)塊的預(yù)處理和渲染器)是相反的,這就是控制反轉(zhuǎn)(IoC),通過(guò)這樣的依賴倒置,我們能夠把多人協(xié)同過(guò)程中,由各個(gè)同學(xué)負(fù)責(zé)開發(fā)的預(yù)處理器和渲染器的開發(fā)調(diào)試解耦出去,互不影響、互不依賴,且合碼過(guò)程中基本沒(méi)有代碼沖突,大大提效了多人協(xié)同合作開發(fā)。同時(shí)由于實(shí)現(xiàn)的方式是依賴注入(DI),或者說(shuō)注冊(cè),未來(lái)我們想要支持更加深水區(qū)的文檔塊,比如「畫板」、「文檔小組件」等,可以很方便地注冊(cè)新的預(yù)處理器和渲染器,做增量且解耦的代碼開發(fā);如果想要取消對(duì)某一個(gè)文檔塊的渲染,直接unregister即可,由此也實(shí)現(xiàn)了文檔塊渲染的快速插拔和極高的可拓展性。
整個(gè)轉(zhuǎn)譯主干代碼如下:
創(chuàng)建轉(zhuǎn)譯器,注冊(cè)預(yù)處理器,注冊(cè)渲染器
轉(zhuǎn)譯渲染,后處理,完成渲染。代碼行數(shù)縮減到只有138行。
函數(shù)式編程
接下來(lái)我們將目光聚焦到核心函數(shù)createDocTranspiler中,這塊是IoC架構(gòu)的核心實(shí)現(xiàn),根據(jù)維基百科描述,IoC是面向?qū)ο缶幊讨械囊环N設(shè)計(jì)原則,那么我們真的是用面向?qū)ο蟮木幊谭绞絾幔?/span>
顯然不是,我們是高標(biāo)準(zhǔn)的前端同學(xué),在JavaScript編程中,面向?qū)ο缶幊田@然不是社區(qū)推崇的設(shè)計(jì)原則,以React框架為例,早在React 16.8版本,就推出了函數(shù)組件和Hooks編程,以取代較為臃腫的類組件編程,這些都是前端老生常談的理念了,大家可以去Google深入學(xué)習(xí)函數(shù)式編程理念,在此不再贅述。
這里說(shuō)一下為什么核心代碼createDocTranspiler我要用函數(shù)式編程,說(shuō)一下我的理解:第一是非常優(yōu)雅,用起來(lái)很舒服;第二是得益于JavaScript函數(shù)閉包,一些局部(想要private化)的變量或者方法,直接在函數(shù)內(nèi)聲明和定義即可,不用擔(dān)心像類一樣會(huì)暴露出去(盡管TS有private關(guān)鍵字,但只是約束,不代表你不能用);第三是簡(jiǎn)單,無(wú)需維護(hù)類的實(shí)例,若有主動(dòng)銷毀場(chǎng)景,返回的結(jié)構(gòu)中暴露銷毀函數(shù)即可。
整個(gè)核心代碼如下:
上方的圖:內(nèi)置的變量和函數(shù),用于存儲(chǔ)各種預(yù)處理器和渲染器,并實(shí)現(xiàn)文檔樹的遞歸渲染;下方的圖:返回并暴露出去的函數(shù),用于注冊(cè)各種預(yù)處理器、渲染器,以及轉(zhuǎn)譯渲染。整個(gè)核心代碼只有158行,非常精煉。
“CSS-in-JS”
然后再來(lái)說(shuō)一下如此大量的樣式還原工作,我們是如何實(shí)現(xiàn)的。由于我們要把文檔樹轉(zhuǎn)譯成最終的一個(gè)完整的HTML字符串,在模板字符串中寫內(nèi)聯(lián)樣式(style="width: 100px;...")會(huì)非常痛苦,代碼可讀性會(huì)很差,開發(fā)調(diào)試的效率也會(huì)很低。
為了解決這個(gè)問(wèn)題,我們立即想到了React CSSProperties的寫法,并調(diào)研了一下它的源碼實(shí)現(xiàn),其實(shí)就是將CSSProperties中的駝峰屬性名,轉(zhuǎn)換成內(nèi)聯(lián)樣式中連字符屬性名,并額外處理了Webkit、ms、Moz、O等瀏覽器屬性前綴,同時(shí)針對(duì)number 類型的部分屬性的值,轉(zhuǎn)換時(shí)自動(dòng)加上了px后綴。詳細(xì)代碼如下:
// 樣式處理工具函數(shù)庫(kù)。
import { CSSProperties } from 'react';
/* 是否是,值可能是數(shù)字類型,且不需要指定 px 為單位的 CSSProperties 屬性。*/
const isUnitlessNumber: Record<string, boolean>={
// ...
fontWeight: true,
lineClamp: true,
lineHeight: true,
// ...
// SVG-related properties.
fillOpacity: true,
floodOpacity: true,
stopOpacity: true,
// ...
};
// 各瀏覽器 CSS 屬性名前綴。
const cssPropertyPrefixes=['Webkit', 'ms', 'Moz', 'O'];
// 針對(duì) isUnitlessNumber,填充各瀏覽器 CSS 屬性名前綴。
Object.keys(isUnitlessNumber).forEach(property=> {
cssPropertyPrefixes.forEach(prefix=> {
isUnitlessNumber[`${prefix}${property.charAt(0).toUpperCase()}${property.substring(1)}`]=isUnitlessNumber[property];
});
});
export { isUnitlessNumber };
/** 針對(duì) CSSProperties 屬性值,可能添加單位 px,并返回合法的值。*/
export function addCSSPropertyUnit<T extends keyof CSSProperties>(property: T, value: CSSProperties[T]) {
if (typeof value==='number' && !isUnitlessNumber[property]) {
// 值是數(shù)字類型,且需要添加單位 px,則添加單位 px。
return `${value}px`;
}
return value;
}
然后再編寫createInlineStyles方法,入?yún)⒓礊镽ecord<string, CSSProperties> 大樣式對(duì)象:
/* 將 CSSProperties 轉(zhuǎn)為內(nèi)聯(lián) style 字符串,e.g. { width: 100, flex: 1 }=> style="width: 100px; flex: 1;"。*/
export function convertCSSPropertiesToInlineStyle(style: CSSProperties) {
const upperCaseReg=/[A-Z]/g;
const inlineStyle=Object.keys(style)
.map(
property=>
`${property.replace(
upperCaseReg,
matchLetter=> `-${matchLetter.toLowerCase()}`,
)}: ${addCSSPropertyUnit(property as keyof CSSProperties, style[property])};`,
)
.join(' ');
if (inlineStyle) {
return `style="${inlineStyle}"`;
}
return '';
}
/** 根據(jù)輸入的樣式表(CSSProperties 格式),輸出內(nèi)聯(lián)樣式表(格式為 style="..." 的字符串),e.g. { container: { position: 'relative' }, title: { fontSize: 18 } }=> { container: 'style="position: relative;"', title: 'style="font-size: 18px;"' }。*/
export function createInlineStyles<T extends string>(styles: { [P in T]: CSSProperties }) {
const inlineStyles={} as { [P in T]: string };
Object.keys(styles).forEach(name=> {
inlineStyles[name]=convertCSSPropertiesToInlineStyle(styles[name]);
});
return inlineStyles;
}
至此架構(gòu)優(yōu)化的差不多了,整個(gè)項(xiàng)目組進(jìn)入了高度協(xié)同、緊密溝通合作的開發(fā)中,整個(gè)開發(fā)過(guò)程其實(shí)并不是特別順利,尤其是在對(duì)Windows Outlook郵箱客戶端的支持上,各種樣式兼容問(wèn)題Case層出不窮,以至于我們的開發(fā)同學(xué)不得不去對(duì)郵箱HTML和CSS開發(fā)進(jìn)行“考古”。
三、Outlook麻煩的兼容性問(wèn)題
在改版系統(tǒng)架構(gòu)后,我們先試著實(shí)現(xiàn)了一版有序列表和無(wú)序列表的解決方案,結(jié)果在測(cè)試中,我們得到了出乎所有人意料之外的結(jié)果:
原本文檔的樣子
網(wǎng)頁(yè)版Outlook中的樣子
Windows的Outlook中的樣子
在網(wǎng)頁(yè)版Outlook中,通過(guò)開發(fā)工具可以看到每一項(xiàng)的justify-content樣式消失了,而在Windows Outlook中,基本沒(méi)什么樣式還留著了。
Outlook糟糕的兼容性
我們之前從未編寫過(guò)HTML郵件,也就完全沒(méi)考慮過(guò)各個(gè)郵件客戶端對(duì)HTML的兼容性問(wèn)題。在網(wǎng)上找到一些資料后,我們被Outlook對(duì)HTML的兼容性之差所震驚。
首先,Windows Outlook并沒(méi)有一個(gè)自己的HTML渲染引擎,而是使用Word的渲染引擎去解析HTML。它不支持HTML5和CSS3,也就是說(shuō)我們?yōu)榱吮WC最大的兼容性,所有的飛書文檔樣式還原和文本解析都要用極為陳舊的技術(shù)去實(shí)現(xiàn)。
據(jù)官方文檔所示,display、position、max-width、max-height等樣式全都不兼容。
總的來(lái)說(shuō):
技術(shù)上的限制如此苛刻,就意味著在后面的開發(fā)中,我們還會(huì)遇到很多特定情況的兼容性問(wèn)題。在這種情況下,為了最大限度地保證兼容性,我們決定及時(shí)止損,重新設(shè)計(jì)后面各個(gè)組件的實(shí)現(xiàn)方式,并將無(wú)序列表和有序列表的渲染方法推倒重來(lái),再次編寫。
四、各類型文檔塊的還原
首先,我們將轉(zhuǎn)譯工具原有的「一級(jí)標(biāo)題」到「九級(jí)標(biāo)題」美化為接近飛書文檔的樣子。我們需要梳理下將會(huì)獲得的數(shù)據(jù),來(lái)看看如何將它們轉(zhuǎn)譯為HTML。
標(biāo)題塊(heading 1-9)
標(biāo)題組件應(yīng)該是實(shí)現(xiàn)難度最低的一個(gè),一個(gè)標(biāo)題組件的數(shù)據(jù)結(jié)構(gòu)如下:
原版實(shí)現(xiàn)方式
在原版的轉(zhuǎn)譯工具中,我們編寫了通用方法來(lái)處理文本內(nèi)容的下劃線、刪除線、斜體、粗體、高亮色等進(jìn)行處理,生成行間元素,然后在外部框上<h1>-<h9>。最終在后面加上它的子節(jié)點(diǎn)渲染結(jié)果。
新版實(shí)現(xiàn)方式
由于默認(rèn)的heading樣式無(wú)法滿足還原度,且并沒(méi)有處理對(duì)齊方式。我們將使用 <div> 制作heading組件,自行添加樣式來(lái)還原飛書文檔:
case BlockType.HEADING1: {
const blockH1=block as HeadingBlock;
const align=blockH1.heading1.style.align;
const styles=makeHeadingStyles({ type: block.block_type, align });
text +=`<div ${styles.headingStyles}>${transpileTextElements(
blockH1.block_id,
blockH1.heading1.elements,
isPreview,
)}</div>`;
// renderChildBlocks 方法來(lái)渲染當(dāng)前塊的所有子節(jié)點(diǎn)。
text +=renderChildBlocks(blockH1.block_id);
break;
}
其中makeHeadingStyles是我們生成樣式的方法,這樣可以將各個(gè)組件的樣式寫成配置項(xiàng),方便后續(xù)修改。新的樣式中,我們著重對(duì)行高、行距、下劃線距文字距離、對(duì)齊方式進(jìn)行了調(diào)整:
// makeHeadingStyles 方法的部分截取。
export function makeHeadingStyles(params: MakeHeadingStylesParams) {
const { type, align }=params;
const basicStyle: CSSProperties={
lineHeight: 1.4,
letterSpacing: '-.02em',
fontWeight: 500,
color: '#1f2329',
textAlign: getTextAlignStyle(align || 1),
};
let headingStyles: CSSProperties={};
switch (type) {
case BlockType.HEADING1:
headingStyles={
fontSize: 26,
marginTop: 26,
marginBottom: 10,
...basicStyle,
};
break;
// 對(duì)Heading2-9的樣式進(jìn)行定義...
// ......
// 將樣式對(duì)象轉(zhuǎn)成行間樣式字符串。
return createInlineStyles<'headingStyles'>({ headingStyles: headingStyles });
}
最后發(fā)郵件,測(cè)試一下生成的HTML的效果:
改版之前
改版之后
無(wú)序列表(bullet)與有序列表(ordered)
原版實(shí)現(xiàn)方式
列表的數(shù)據(jù)結(jié)構(gòu)與標(biāo)題塊大致相同,在此不再贅述。在原來(lái)的轉(zhuǎn)譯工具中,我們使用原生的<ul>和<li>來(lái)直接渲染無(wú)序列表,<ol><li>來(lái)渲染有序列表。我們順序遍歷兄弟節(jié)點(diǎn)的列表,為連續(xù)的bullet文檔塊的前后加上<ul></ul>,連續(xù)的ordered塊前后加上<ol>和</ol>。列表中的每一項(xiàng),則渲染成<li>。
由于原生<ul>和<ol>的marker樣式較丑,我們無(wú)法使用偽類元素等手段改善它的樣式,為了方便,我們這次改版將自己維護(hù)列表的層級(jí)關(guān)系。
新版實(shí)現(xiàn)方式
在飛書文檔中,不同層級(jí)的列表,marker長(zhǎng)得完全不同:
無(wú)序列表
有序列表
為了判斷我們每個(gè)列表項(xiàng)要使用什么樣的marker,首先我們需要對(duì)飛書給我們的數(shù)據(jù)進(jìn)行預(yù)處理,為每個(gè)列表塊標(biāo)注它的層級(jí)和序號(hào)。
由于飛書API沒(méi)有提供有序列表的序號(hào),這個(gè)序號(hào)用戶又可以隨便更改,所以我們的思路是:如果有序列表中間被非空文檔塊以外的文本塊截?cái)啵蛱?hào)則重新開始計(jì)算。具體方法如下:
/** 判斷文本塊是否為空白文本類型的快。*/
export function isEmptyTextBlock(block: DocBlockText | undefined) {
if (文檔塊的類型為text且不為空 || 文檔塊類型不為text) {返回false;}
else {返回true;}
}
/** 為每個(gè)文本塊計(jì)算它到文本樹根節(jié)點(diǎn)的深度,為有序列表塊找到它的序號(hào)。*/
export function processBlocks(blocks: DocBlock[]) {
const blockDepths={}; // 記錄各節(jié)點(diǎn)距根節(jié)點(diǎn)的深度。
const blockOrder={}; // 記錄各節(jié)點(diǎn)在同類兄弟節(jié)點(diǎn)中的順序,被其他類型的塊打斷的時(shí)候?qū)⒅匦掠?jì)數(shù)。
function calcBlockFields(block: DocBlock, depth: number) {
blockDepths[block.block_id]=depth;
// 為有序列表找到它的序號(hào)。
if (文本塊類型為 ordered) {
1. 找到同級(jí)兄弟節(jié)點(diǎn)列表 brotherBlocks 與同類型同級(jí)兄弟節(jié)點(diǎn)列表 similarBrotherBlocks;
2. 找到當(dāng)前節(jié)點(diǎn)在上述兩個(gè)列表中的索引 brotherBlocksIndex,similarBrotherBlocksIndex;
3. 找到兄弟節(jié)點(diǎn)列表中的前一個(gè)節(jié)點(diǎn) prevBrotherBlock。以及同類兄弟列表的前一個(gè)節(jié)點(diǎn) prevSimilarBrotherBlock;
if (當(dāng)前節(jié)點(diǎn)是兄弟節(jié)點(diǎn)列表中的第一個(gè)節(jié)點(diǎn) || 當(dāng)前節(jié)點(diǎn)是同類兄弟節(jié)點(diǎn)列表中的第一個(gè)節(jié)點(diǎn) || 前一個(gè)兄弟節(jié)點(diǎn)不是同類兄弟節(jié)點(diǎn),且前一個(gè)兄弟節(jié)點(diǎn)是非空的文本塊) {
blockOrder[block.block_id]=1;
} else {
blockOrder[block.block_id]=上一個(gè)同類兄弟的編號(hào) + 1
}
}
遞歸處理子節(jié)點(diǎn)。如果當(dāng)前節(jié)點(diǎn)的類型為 grid_column、tabel_cell、callout、quoter_container 的時(shí)候,深度重置為 1(calcBlockFields(childrenBlock, 1)),其他情況 calcBlockFields(childrenBlock, depth + 1);
}
從根節(jié)點(diǎn)開始遞歸處理。calcBlockFields(rootBlock, 0);
將記錄的序號(hào)和深度(blockOrder, blockDepths)添加到每個(gè)節(jié)點(diǎn)中(block.depth, block.order);
}
這樣,每個(gè)列表項(xiàng)都知道了自己在文檔中的層級(jí),有序列表也知道了自己的序號(hào)。
由于原來(lái)的方法中完全沒(méi)有處理過(guò)文本塊的縮進(jìn),我們根據(jù)飛書縮進(jìn)的規(guī)律,為普通的文本塊(表格、柵格等以外的文本塊)在渲染子節(jié)點(diǎn)時(shí)為子節(jié)點(diǎn)的容器添加25px的padding-left。
接下來(lái)我們使用一個(gè)通用的方法為有序列表和無(wú)序列表渲染它們的marker。
/** 渲染列表的標(biāo)簽。*/
export const listMarkRender=(type: ListType, block: DocBlock)=> {
const { depth=1, order=1 }=block;
if (type===ListType.BULLET) {
const styles=makeMarkerStyles(ListType.BULLET);
let marker: string;
marker=按照深度,每三個(gè)一循環(huán),依次為 '?'、'?'、'?';
return `<span ${styles.markContainerStyle}>${marker}</span>`;
} else {
const styles=makeMarkerStyles(ListType.ORDERED);
let markerGenerator: (num: number)=> number | string;
markerGenerator=按照深度,每三個(gè)一循環(huán),依次為數(shù)字、數(shù)字轉(zhuǎn)小寫字母、數(shù)字轉(zhuǎn)羅馬數(shù)字;
return `<span ${styles.markContainerStyle}>${markerGenerator(order)}.</span>`;
}
};
對(duì)于無(wú)序列表,標(biāo)號(hào)每三層一循環(huán),順序?yàn)?'?'、'?'、'?'。對(duì)于有序列表,標(biāo)號(hào)格式也是每三層一循環(huán),順序?yàn)榘⒗當(dāng)?shù)字、小寫字母、羅馬數(shù)字。
使用列表的標(biāo)號(hào)渲染器渲染標(biāo)號(hào)部分,然后簡(jiǎn)單的在<div>中將標(biāo)號(hào)<span>和處理過(guò)樣式的正文<span>組合。
const orderedRenderer: BlockRenderer=(block, isPreview, renderChildBlocks)=> {
const orderedBlock=block as OrderedBlock;
const align=orderedBlock.ordered.style.align;
const styles=makeOrderedStyles(align);
let text='';
text +=`
<div ${styles.listWrapper}>
${listMarkRender(ListType.ORDERED, orderedBlock,)}
<span ${styles.listContent}>
${transpileTextElements(orderedBlock.block_id, orderedBlock.ordered.elements, isPreview,)}
</span>
</div>
`;
text +=renderChildBlocks(orderedBlock.block_id, false);
return text;
};
const bulletRenderer: BlockRenderer=(block, isPreview, renderChildBlocks)=> {
const bulletBlock=block as BulletBlock;
const align=bulletBlock.bullet.style.align;
const styles=makeBulletStyles(align);
let text='';
text +=`
<div ${styles.listWrapper}>
${listMarkRender(ListType.BULLET, bulletBlock,)}
<span ${styles.listContent}>${transpileTextElements(
bulletBlock.block_id,
bulletBlock.bullet.elements,
isPreview,
)}</span>
</div>`;
text +=renderChildBlocks(bulletBlock.block_id, false);
return text;
};
可以看到,我們?cè)跐M足使用的前提下以最高的兼容性比較完美的還原了飛書文檔中的有序列表和無(wú)序列表。
待辦事項(xiàng)
既然漂亮地還原了有序列表和無(wú)序列表,待辦事項(xiàng)塊就簡(jiǎn)單得多了。代辦事項(xiàng)的具體的數(shù)據(jù)結(jié)構(gòu)如下:
可以看到,待辦事項(xiàng)的數(shù)據(jù)中包含了該條待辦事項(xiàng)是否已完成的數(shù)據(jù),從飛書文檔的樣式可以看出,已完成的條目會(huì)統(tǒng)一被劃上刪除線,并刪除下劃線樣式。最終的渲染器和樣式生成方法如下:
待辦事項(xiàng)渲染器
const todoRenderer: BlockRenderer=(block, isPreview, renderChildBlocks, _blocks)=> {
const todoBlock=block as TodoBlock;
const { align, done }=todoBlock.todo.style;
const originTodoElements=todoBlock.todo.elements;
const markerSrc=done ? '已完成標(biāo)記圖片地址' : '未完成標(biāo)記圖片地址';
const styles=makeTodoStyles(align || 1, done);
const checkedTodoElements=cloneDeep(originTodoElements);
checkedTodoElements.forEach(element=> {
為所有文本元素去掉下劃線,添加刪除線
});
let text='';
text +=`
<div ${styles.todoWrapperStyles}>
<img width="18" height="18" ${styles.todoMarkerStyles} src="${markerSrc}" alt="todo_mark"/>
<span> </span>
<span ${styles.todoContentStyles}>${transpileTextElements(
todoBlock.block_id,
done ? checkedTodoElements : originTodoElements,
isPreview,
)}</span>
</div>`;
text +=renderChildBlocks(todoBlock.block_id, false);
return text;
};
最終呈現(xiàn)效果
表格(非電子表格)塊
文檔中另一個(gè)最重要的模塊就是表格。表格是另一類比較特殊的文本塊,他內(nèi)部并不包含正文。整個(gè)表格實(shí)際上由三層文檔塊組合而成,它們的數(shù)據(jù)結(jié)構(gòu)如下:
依據(jù)數(shù)據(jù)結(jié)構(gòu)和我們的代碼模式設(shè)計(jì),我們需要使用嵌套的渲染器來(lái)實(shí)現(xiàn)表格的繪制。
表格渲染器(table塊)
由于飛書API中清楚地提供了行數(shù)、列數(shù)以及列寬,我們可以較為輕松地繪制出大致的表格。這里的重點(diǎn)是要準(zhǔn)確地處理合并單元格數(shù)據(jù),將它們精準(zhǔn)地使用在表格的每個(gè) <td>標(biāo)簽上。表格渲染器的代碼如下:
const tableRenderer: BlockRenderer=(block, renderSpecifyBlock)=> {
const blockTable=block as TableBlock;
const children=blockTable.table.cells;
const tableStyles=makeTableStyles();
const { column_size, row_size, column_width, merge_info }=blockTable.table.property;
// 計(jì)算出整個(gè)表格的整體寬度。
const totalWidth=column_width.reduce((acc, cur)=> acc + cur, 0);
let text=`
<div ${tableStyles.tableWrapperStyles}>
<table width="${totalWidth}" ${tableStyles.tableStyles}>
`;
// 初始化單元格處理標(biāo)記數(shù)組,記錄哪些單元格已被處理過(guò)數(shù)據(jù)。
const processed=Array.from({ length: row_size }, ()=> Array(column_size).fill(false));
let mergeIndex=0; // 追蹤當(dāng)前 merge_info 索引。
for (let i=0; i < row_size; i++) {
text +='<tr>';
for (let j=0; j < column_size; ) {
從 merge_info[mergeIndex] 獲取當(dāng)前合并信息 col_span 與 row_span,確保 col_span 和 row_span 至少為 1;
// 如果當(dāng)前單元格未處理過(guò),則進(jìn)行處理。
if (!processed[i][j]) {
const tDStyles=makeTDStyles(column_width[j]);
const colspanAttr=col_span > 1 ? `colspan="${col_span}"` : '';
const rowspanAttr=row_span > 1 ? `rowspan="${row_span}"` : '';
text +=`
<td valign="top" width="${column_width[j]}" ${colspanAttr} ${rowspanAttr} ${
tDStyles.tDStyles
}>
// 與之前的文檔塊直接渲染所有的子節(jié)點(diǎn)不同,表格需要在單元格內(nèi)精準(zhǔn)的渲染對(duì)應(yīng)的 table cell 塊,所以此處使用 renderSpecifyBlock 方法。
${renderSpecifyBlock(children[i * column_size + j])}
</td>
`;
// 更新處理標(biāo)記數(shù)組,標(biāo)記當(dāng)前單元格及其被合并的單元格為已處理,
for (let m=i; m < Math.min(i + row_span, row_size); m++) {
for (let n=j; n < Math.min(j + col_span, column_size); n++) {
processed[m][n]=true;
}
}
j +=col_span; // 跳過(guò)被合并的單元格。
mergeIndex +=col_span; // 跳過(guò)被合并的單元格對(duì)應(yīng)的 merge_info。
} else {
j++;
mergeIndex++;
}
}
text +='</tr>';
}
text +='</table></div>';
return text;
};
為了處理合并單元格數(shù)據(jù),我們維護(hù)了一個(gè)已處理標(biāo)記數(shù)組processed,處理完一個(gè)單元格后,我們將當(dāng)前單元格與被它合并的單元格都標(biāo)記為已處理,來(lái)跳過(guò)他們的處理與渲染。這里需要特別注意,飛書文檔的接口偶爾會(huì)返回錯(cuò)誤的合并單元格數(shù)據(jù):{ row_span: 0, col_span: 0 },這個(gè)現(xiàn)象已經(jīng)反饋給飛書,我們?cè)?4-37行做了兼容處理。
為了最大限度的兼容性,我們堅(jiān)持能用標(biāo)簽屬性設(shè)置的樣式,就不使用CSS來(lái)設(shè)置。與列表的渲染不同,在表格中我們沒(méi)有像列表渲染一樣先預(yù)處理數(shù)據(jù)再生成DOM字符串,而是使用了在遍歷中邊處理數(shù)據(jù)邊生成DOM字符串的方法。
在表格的渲染中,我們沒(méi)有像之前的代碼一樣使用renderChildBlocks把所有子文檔塊都渲染出來(lái)添加進(jìn)HTML字符串中,而是使用了新的renderSpecifyBlock方法,給定block_id來(lái)渲染特定的子文檔塊。
單元格容器渲染器(table cell塊)
單元格容器的渲染器則簡(jiǎn)單的多,他沒(méi)有任何數(shù)據(jù)處理,只繪制一個(gè)容器用于承載內(nèi)部的所有子節(jié)點(diǎn),并在內(nèi)部將單元格內(nèi)的子節(jié)點(diǎn)渲染出來(lái)
const tableCellRenderer: BlockRenderer=(block, isPreview, renderChildBlocks, _blocks)=> {
const styles=makeTableCellStyles();
return `
<div ${styles.tableCellWrapperStyle}>
${renderChildBlocks(block.block_id, true)}
</div>`;
};
最終呈現(xiàn)效果
圖片塊
圖片塊理應(yīng)也是一個(gè)很容易實(shí)現(xiàn)的文檔塊。但在實(shí)際處理過(guò)程中,由于飛書的API只提供圖片源文件的寬高,并沒(méi)有提供云文檔中用戶縮放過(guò)后的圖片寬高,我們需要實(shí)現(xiàn)一個(gè)能滿足絕大多數(shù)使用場(chǎng)景的圖片縮放算法來(lái)盡可能還原文檔中的圖片樣式。
圖片塊的數(shù)據(jù)結(jié)構(gòu)如下:
限制圖片大小
源文件的寬高一般都遠(yuǎn)大于圖片在云文檔中的實(shí)際寬高。我決定使用以下的方法來(lái)限制住圖片在文檔中的寬高:
上述算法的代碼實(shí)現(xiàn)如下:
/** 根據(jù) id 找到塊。*/
function findNodeById(blocks: DocBlock[], id: string) {
return blocks.find(b=> b.block_id===id);
}
/** 檢查當(dāng)前塊的父節(jié)點(diǎn)中有沒(méi)有表格或柵格塊。*/
function checkIsInTable(blocks: DocBlock[], parentId: string) {
const parentNode=findNodeById(blocks, parentId);
if (parentNode) {
if (WRAPPERS_LIKE_TABLE.includes(parentNode.block_type)) {
return true;
}
return checkIsInTable(blocks, parentNode.parent_id);
}
return false;
}
function restrictImageSize(
width: number,
height: number,
maxWidth: number=820,
maxHeight: number=780,
): [number, number] {
// 寬和高按照長(zhǎng)邊縮放(高度大于寬度 50px 視為長(zhǎng)圖),并為縮放后的寬高向上取整。
if (width >=height - 50) {
if (width > maxWidth) {
return [maxWidth, Math.ceil(height * divide(maxWidth, width))];
}
} else {
if (height > maxHeight) {
return [Math.ceil(width * divide(maxHeight, height)), maxHeight];
}
}
return [width, height];
}
圖片渲染器
const imageRenderer: BlockRenderer=(block, isPreview, _renderChildBlocks, blocks)=> {
let text='';
const blockImage=block as DocBlockImage;
const align=blockImage.image.align;
const src=`"${
isPreview ? blockImage.image.base64Url : `\$\{${blockImage.block_id}\}` // 實(shí)際發(fā)送時(shí),用 ${block_id} 作為占位符,給到服務(wù)端填充圖片附件地址。
}"`;
const [width]=restrictImageSize(blockImage.image.width, blockImage.image.height);
const isInTable=checkIsInTable(blocks, blockImage.parent_id);
const styles=makeImageStyles({ width, align, isInTable });
text +=`
<div ${styles.imgWrapperStyle}>
<img width="${isInTable ? '100%' : width}" ${styles.imgStyle} src=${src}>
</div>
`;
return text;
};
在預(yù)覽的時(shí)候,我們將圖片地址設(shè)為圖片的base64,直接展示。最后傳給后端的HTML字符串中,我們將圖片地址設(shè)為一個(gè)占位符,供后端解析并轉(zhuǎn)化為郵件附件地址。
使用表格來(lái)布局的幾個(gè)文檔塊
由于Windows Outlook對(duì)CSS的支持程度很差,我們?cè)趯?duì)一些復(fù)雜文檔塊進(jìn)行排版布局的時(shí)候不能使用flex、grid等。且display和position屬性在大多情況下也不會(huì)像預(yù)期那樣正常生效。我們?yōu)榱俗畲蟮募嫒菪灾荒苁褂帽砀駚?lái)解決一切排版問(wèn)題。代碼塊、高亮塊、柵格等幾個(gè)文檔塊就都遵循了這個(gè)思路,使用表格來(lái)解決排版。我們以最復(fù)雜的代碼塊作為代表來(lái)進(jìn)行介紹。
代碼塊
飛書云文檔中免不了會(huì)出現(xiàn)代碼,所以較好的進(jìn)行代碼塊的還原也是個(gè)重要的工作。代碼塊還原的一個(gè)難點(diǎn)就是數(shù)據(jù)的處理,首先介紹下代碼塊的數(shù)據(jù)結(jié)構(gòu):
理想的話,我們希望element中每一項(xiàng)為一行代碼,我們挨個(gè)進(jìn)行渲染即可。但實(shí)際上,element的內(nèi)容和普通文本類似,只要文本的樣式不變(比如設(shè)為斜體、加粗等),這些文本就都會(huì)被塞到同一個(gè)element項(xiàng)中。
舉例說(shuō)明,對(duì)于下列文檔中的代碼塊,實(shí)際飛書API返回的代碼只有兩項(xiàng)element:
其中,最后一個(gè)大括號(hào)被單獨(dú)拆成一項(xiàng)令人費(fèi)解,不過(guò)好在代碼塊中,只要一項(xiàng)element的后面出現(xiàn)了另一項(xiàng),那就一定意味著換行。這減少了我們的處理難度。
我們的大體思路,是將代碼拆分成一個(gè)二維數(shù)組。第一維中的每一維度為一行代碼,每行代碼中的每一維度為拆分后零碎的代碼塊。我們先將所有的element中的內(nèi)容根據(jù)換行符\n拆分成一個(gè)個(gè)細(xì)小的子塊,同時(shí)將與HTML有關(guān)的字符替換成HTML編碼,避免這些字符混入HTML字符串中被當(dāng)做標(biāo)簽解析:
elements.forEach(element=> {
const textStyles=element.text_run?.text_element_style;
const elementSplit=(element.text_run?.content || '')
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''')
.match(/(.*?\n|.+)/g);
elementSplit &&
elementSplit.forEach(line=> {
codeList.push({
text_run: {
content: line,
text_element_style: textStyles as TextElementStyle,
},
});
});
});
然后將這些子塊按照換行符進(jìn)行分組,變成我們需要的二維數(shù)組:
/** 將拆分好的代碼塊列表按行進(jìn)行分組。*/
const groupingCodeList=(list: TextElement[]=[])=> {
const result: TextElement[][]=[];
let currentGroup: TextElement[]=[];
list.forEach(item=> {
// 將當(dāng)前字符串添加到當(dāng)前分組。
currentGroup.push(item);
// 如果字符串包含 '\n',則結(jié)束當(dāng)前分組,并準(zhǔn)備開始新的分組。
if (item.text_run?.content.includes('\n')) {
result.push(currentGroup);
currentGroup=[];
}
});
// 最后將 currentGroup 中剩余的項(xiàng)目加入 result。
if (currentGroup.length > 0) {
result.push(currentGroup);
}
return result;
};
至此,我們知道了代碼行數(shù)n和每行代碼中的小代碼塊有哪些。我們要做的就是將它們放進(jìn)一個(gè)n行2列的表格中
最終,代碼塊渲染器的代碼如下。為了保證最大的兼容性,我們使用空的表格行作為內(nèi)邊距,盡量避免CSS解析問(wèn)題:
const codeRenderer: BlockRenderer=(block, isPreview, renderChildBlocks, _blocks)=> {
const styles=makeCodeStyles();
const blockCode=block as DocBlockCode;
const codeLanguage=blockCode.code.style.language || 0;
// 將代碼塊中的正文將帶 \n 的分割開。
const codeList: TextElement[]=[];
const elements=blockCode.code.elements;
// 分割的時(shí)候把 HTML 有關(guān)的字符換成 HTML 編碼,避免這些正文直接被當(dāng)成 HTML 渲染。
上文中提到的對(duì)elements的處理...
const groupedCodeLines=groupingCodeList(codeList);
// 將按行分類好的代碼塊填入 td。
const codeTr=groupedCodeLines
.map((line, index)=> {
return `
<tr bgcolor="f5f6f7">
<td width="46" align="right" valign="top">
<pre ${styles.codeIndexStyles}>${index + 1}</pre>
</td>
<td>
<pre ${styles.codePreStyles}>${transpileTextElements(blockCode.block_id, line, isPreview,)}</pre>
</td>
</tr>
`;
})
.join('');
const emptyTr=`
<tr bgcolor="f5f6f7">
<td width="46" align="right"><span> </span></td>
<td><pre ${styles.codePreStyles}> </pre></td>
</tr>
`;
let text=`
<div ${styles.codeWrapperStyles}>
<table width="100%" ${styles.codeTableStyles}>
${emptyTr}
${codeTr}
${emptyTr}
</table>
</div>
`;
text +=renderChildBlocks(blockCode.block_id, false);
return text;
};
我們本次不會(huì)實(shí)現(xiàn)代碼的高亮,只會(huì)顯示同一種顏色的代碼。對(duì)表格中的每個(gè)單元格,我們使用pre標(biāo)簽包裹來(lái)保留代碼中的制表符、空格,并將fontFamily設(shè)置為'Courier New', Courier, monospace,使用等寬字體來(lái)呈現(xiàn)代碼。
行間公式
飛書云文檔除文本外支持多種行間元素的插入,比如@文檔、內(nèi)聯(lián)文件、內(nèi)聯(lián)公式等,在此我們介紹下最為復(fù)雜的內(nèi)聯(lián)公式是怎么處理的。
行間公式的數(shù)據(jù)位于各個(gè)文檔塊的內(nèi)聯(lián)塊中,以文本塊為例,具體數(shù)據(jù)如下:
我們要做的,就是將公式轉(zhuǎn)換為圖片,然后在郵件中將公式作為圖片附件來(lái)處理。
我們將使用MathJax來(lái)將公式表達(dá)式轉(zhuǎn)換為svg,用于用戶預(yù)覽。在發(fā)送時(shí),我們將MathJax生成的svg通過(guò)cavans轉(zhuǎn)化為png圖片,上傳到CDN,并將CDN地址給到后端,進(jìn)行郵件附件轉(zhuǎn)換。
公式的預(yù)處理方法如下:
// 公式發(fā)送時(shí),后端渲染完成的圖片,其展示的高度的系數(shù)。
const equationCoefficient=8.421;
const enrichEquationElements: BlockPreprocessor=async (blocks, isPreview)=> {
if (!window.MathJax) {
await loadScript('https://cdn.dewu.com/node-common/bc7b5cfc-1c7c-e649-710a-929f109e505e.js');
}
const equationSVGList: SvgObj[]=[]; // 待上傳的公式列表。
const equationElementList: TextElement[]=[]; // 帶有公式的元素列表。
blocks.forEach(block=> {
const elements=getBlockElements(block);
let equationIndex=0;
elements.forEach(textEl=> {
// 文本塊內(nèi)容中包含公式時(shí),轉(zhuǎn)譯為 SVG HTML。
if (textEl.equation) {
equationElementList.push(textEl);
const equationId=`${block.block_id}_equation_${++equationIndex}`;
const svgEl=window.MathJax.tex2svg(textEl.equation.content).children[0];
// 由于生成的公式 svg 的高度使用 ex 單位,這里乘以一個(gè)參數(shù)來(lái)轉(zhuǎn)成近似的 px 單位。
const svgHeight=svgEl的ex高度 * equationCoefficient;
const svgWidth=svgEl的ex寬度 * equationCoefficient;
textEl.equation.svgHTML=svgEl.outerHTML;
textEl.equation.imageHeight=svgHeight;
textEl.equation.imageWidth=svgWidth;
textEl.equation.id=equationId;
equationSVGList.push({
id: equationId,
svg: svgEl.outerHTML,
height: svgHeight,
width: svgWidth,
});
}
});
});
// 非本地預(yù)覽的時(shí)候進(jìn)行公式轉(zhuǎn)圖片并上傳 CDN(本地環(huán)境由于跨域無(wú)法上傳 CDN)。
if (!isPreview) {
OSS 上傳配置...
// 公式 svg 轉(zhuǎn)圖片文件然后上傳 OSS。
const res=await allSvgsToImgThenUpload(equationSVGList);
equationElementList.forEach(element=> {
從res中找到當(dāng)前公式元素對(duì)應(yīng)的圖片,放入element.equation.imageUrl中
});
}
};
我們先找出所有文檔塊中的內(nèi)聯(lián)公式,將其轉(zhuǎn)換為svg,存儲(chǔ)到公式塊中。如果當(dāng)前是發(fā)送模式,不是預(yù)覽模式,我們就做進(jìn)一步處理,使用allSvgsToImgThenUpload 將svg再轉(zhuǎn)化為圖片的CDN地址,此處的allSvgsToImgThenUpload方法讓我們并行處理所有的公式圖片,具體如下:
function allSvgsToImgThenUpload(svgObjList: SvgObj[]) {
// 將每個(gè) SVG 字符串映射到轉(zhuǎn)換函數(shù)的調(diào)用上。
const conversionPromises=svgObjList.map(svgObj=> svgToImgThenUpload(svgObj));
// 使用 Promise.all 等待所有圖片完成轉(zhuǎn)換和上傳。
return Promise.all(conversionPromises);
}
核心的svgToImgThenUpload方法如下,它負(fù)責(zé)將svg轉(zhuǎn)化為圖片,并上傳CDN:
/** svg 轉(zhuǎn)圖片,并上傳到 OSS。*/
function svgToImgThenUpload(svgObj: SvgObj): Promise<{ id: string; url: string }> {
return new Promise((resolve, reject)=> {
const { width, height, id }=svgObj;
const svgString=svgObj.svg;
if (!width || !height) {
reject(`公式svg大小獲取失敗: ${id}`);
return;
}
// 生成 svg 的 base64 編碼。
const encodedString=encodeURIComponent(svgString).replace(/'/g, '%27').replace(/"/g, '%22');
const dataUrl='data:image/svg+xml,' + encodedString;
// 使用 canvas 渲染 svg 并轉(zhuǎn)為圖片。
const image=new Image();
image.onload=()=> {
const canvas=document.createElement('canvas');
// 為了保證圖片清晰,渲染使用三倍寬高,實(shí)際大小使用兩倍寬高。
canvas.width=width * 3;
canvas.height=height * 3;
canvas.style.width=`${width * 2}px`;
canvas.style.height=`${height * 2}px`;
const ctx=canvas.getContext('2d');
ctx && ctx.drawImage(image, 0, 0, width * 3, height * 3);
// 將 canvas 內(nèi)容導(dǎo)出為 Blob。
canvas.toBlob(async blob=> {
創(chuàng)建 File 對(duì)象并上傳 CDN,返回 CDN 鏈接;
}, 'image/png');
};
image.onerror=reject;
image.src=dataUrl;
});
}
為了保證圖片清晰,渲染使用三倍寬高,實(shí)際大小使用兩倍寬高。
至此,我們讓公式塊帶上了圖片CDN地址。在發(fā)送時(shí)交給后端,轉(zhuǎn)為郵件附件,即可正常顯示了。
五、向前一步
好在最終我們克服了重重困難,終于來(lái)到了轉(zhuǎn)譯工具升級(jí)的Showcase環(huán)節(jié)。之前有提到我們有fallbackRenderer,主要用于針對(duì)未識(shí)別或者未支持的文檔塊,渲染其默認(rèn)提示,最初我們渲染的效果只是一個(gè)簡(jiǎn)單的提示,比如:【畫板暫不支持解析】這樣的文案提示。
但是我們很快發(fā)現(xiàn):1. 這些提示并不明顯,可以做一個(gè)類似Antd Alert的提示;2. 在發(fā)送時(shí)要過(guò)濾掉這些提示,因?yàn)槭菬o(wú)效信息;3. 在預(yù)覽時(shí)需要讓用戶能夠看到實(shí)際的發(fā)送效果,需要有開關(guān)能隱藏這些提示;4. 發(fā)送時(shí)存在這些不支持的塊時(shí),需要攔截提示用戶是否去調(diào)整文檔內(nèi)容,以達(dá)到信息更全效果更好的發(fā)送效果。往往是這些細(xì)枝末節(jié)的體驗(yàn)與引導(dǎo),能夠真正抓住用戶的心,讓用戶覺(jué)得這個(gè)轉(zhuǎn)譯工具是真的貼心、好用。
因此,我們快速增加了這些具體的引導(dǎo)與提示優(yōu)化,具體效果如下:
六、大功告成
經(jīng)過(guò)這一番波折,我們最終成功地將飛書云文檔轉(zhuǎn)譯為兼容大多數(shù)客戶端的HTML郵件。這不僅僅是一項(xiàng)技術(shù)上的挑戰(zhàn),更是一次心態(tài)和耐心的考驗(yàn)。
在這個(gè)過(guò)程中,我們深刻體會(huì)到在前端開發(fā)中,面對(duì)各種瀏覽器和客戶端的不一致性時(shí),需要的不僅僅是技術(shù)能力,還需要靈活應(yīng)變和堅(jiān)持不懈的精神。希望本文能為同樣遇到這些問(wèn)題的開發(fā)者提供一些思路和幫助。
未來(lái),我們還將繼續(xù)優(yōu)化我們的解決方案,并探索更多高效的方法,期待與大家分享更多經(jīng)驗(yàn)。如果有任何問(wèn)題或建議,歡迎在評(píng)論區(qū)留言討論!
感謝閱讀!
引用:
*文/ Nicolas、Asher
本文屬得物技術(shù)原創(chuàng),未經(jīng)得物技術(shù)許可嚴(yán)禁轉(zhuǎn)載,否則依法追究法律責(zé)任!
*請(qǐng)認(rèn)真填寫需求信息,我們會(huì)在24小時(shí)內(nèi)與您取得聯(lián)系。