A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
1import {setStorageVal, updateHotkeyTip} from "../util/compatibility";
2import {ToolbarItem} from "./ToolbarItem";
3import {setPosition} from "../../util/setPosition";
4import {focusByRange, getSelectionPosition} from "../util/selection";
5import {Constants} from "../../constants";
6import {hasClosestBlock, hasClosestByAttribute} from "../util/hasClosest";
7import {updateBatchTransaction} from "../wysiwyg/transaction";
8import {lineNumberRender} from "../render/highlightRender";
9
10export class Font extends ToolbarItem {
11 public element: HTMLElement;
12
13 constructor(protyle: IProtyle, menuItem: IMenuItem) {
14 super(protyle, menuItem);
15 this.element.addEventListener("click", () => {
16 protyle.toolbar.element.classList.add("fn__none");
17 protyle.toolbar.subElement.innerHTML = "";
18 protyle.toolbar.subElement.style.width = "";
19 protyle.toolbar.subElement.style.padding = "";
20 protyle.toolbar.subElement.append(appearanceMenu(protyle, getFontNodeElements(protyle)));
21 protyle.toolbar.subElement.style.zIndex = (++window.siyuan.zIndex).toString();
22 protyle.toolbar.subElement.classList.remove("fn__none");
23 protyle.toolbar.subElementCloseCB = undefined;
24 focusByRange(protyle.toolbar.range);
25 /// #if !MOBILE
26 const position = getSelectionPosition(protyle.wysiwyg.element, protyle.toolbar.range);
27 setPosition(protyle.toolbar.subElement, position.left, position.top + 18, 26);
28 /// #endif
29 });
30 }
31}
32
33export const appearanceMenu = (protyle: IProtyle, nodeElements?: Element[]) => {
34 let colorHTML = "";
35 ["", "var(--b3-font-color1)", "var(--b3-font-color2)", "var(--b3-font-color3)", "var(--b3-font-color4)",
36 "var(--b3-font-color5)", "var(--b3-font-color6)", "var(--b3-font-color7)", "var(--b3-font-color8)",
37 "var(--b3-font-color9)", "var(--b3-font-color10)", "var(--b3-font-color11)", "var(--b3-font-color12)",
38 "var(--b3-font-color13)"].forEach((item) => {
39 colorHTML += `<button ${item ? `class="color__square" style="color:${item}"` : `class="color__square ariaLabel" data-position="3south" aria-label="${window.siyuan.languages.default}"`} data-type="color">A</button>`;
40 });
41 let bgHTML = "";
42 ["", "var(--b3-font-background1)", "var(--b3-font-background2)", "var(--b3-font-background3)", "var(--b3-font-background4)",
43 "var(--b3-font-background5)", "var(--b3-font-background6)", "var(--b3-font-background7)", "var(--b3-font-background8)",
44 "var(--b3-font-background9)", "var(--b3-font-background10)", "var(--b3-font-background11)", "var(--b3-font-background12)",
45 "var(--b3-font-background13)"].forEach((item) => {
46 bgHTML += `<button ${item ? `class="color__square" style="background-color:${item}"` : `class="color__square ariaLabel" data-position="3south" aria-label="${window.siyuan.languages.default}"`} data-type="backgroundColor"></button>`;
47 });
48
49 const element = document.createElement("div");
50 element.classList.add("protyle-font");
51 let disableFont = false;
52 nodeElements?.find((item: HTMLElement) => {
53 if (item.classList.contains("li")) {
54 disableFont = true;
55 return true;
56 }
57 });
58 let lastColorHTML = "";
59 const lastFonts = window.siyuan.storage[Constants.LOCAL_FONTSTYLES];
60 if (lastFonts.length > 0) {
61 lastColorHTML = `<div data-id="lastUsed" class="fn__flex">
62 ${window.siyuan.languages.lastUsed}
63 <span class="fn__space"></span>
64 <kbd class="fn__kbd fn__flex-center${window.siyuan.config.keymap.editor.insert.lastUsed.custom ? "" : " fn__none"}">${updateHotkeyTip(window.siyuan.config.keymap.editor.insert.lastUsed.custom)}</kbd>
65</div>
66<div class="fn__hr--small"></div>
67<div data-id="lastUsedWrap" class="fn__flex fn__flex-wrap" style="align-items: center">`;
68 lastFonts.forEach((item: string) => {
69 const lastFontStatus = item.split(Constants.ZWSP);
70 switch (lastFontStatus[0]) {
71 case "color":
72 lastColorHTML += `<button class="color__square ariaLabel" data-position="3south" aria-label="${window.siyuan.languages.colorFont}${lastFontStatus[1] ? "" : " " + window.siyuan.languages.default}" ${lastFontStatus[1] ? `style="color:${lastFontStatus[1]}"` : ""} data-type="${lastFontStatus[0]}">A</button>`;
73 break;
74 case "backgroundColor":
75 lastColorHTML += `<button class="color__square ariaLabel" data-position="3south" aria-label="${window.siyuan.languages.colorPrimary}${lastFontStatus[1] ? "" : " " + window.siyuan.languages.default}" ${lastFontStatus[1] ? `style="background-color:${lastFontStatus[1]}"` : ""} data-type="${lastFontStatus[0]}"></button>`;
76 break;
77 case "style2":
78 lastColorHTML += `<button data-type="${lastFontStatus[0]}" class="protyle-font__style" style="-webkit-text-stroke: 0.2px var(--b3-theme-on-background);-webkit-text-fill-color : transparent;">${window.siyuan.languages.hollow}</button>`;
79 break;
80 case "style4":
81 lastColorHTML += `<button data-type="${lastFontStatus[0]}" class="protyle-font__style" style="text-shadow: 1px 1px var(--b3-theme-surface-lighter), 2px 2px var(--b3-theme-surface-lighter), 3px 3px var(--b3-theme-surface-lighter), 4px 4px var(--b3-theme-surface-lighter)">${window.siyuan.languages.shadow}</button>`;
82 break;
83 case "fontSize":
84 if (!disableFont) {
85 lastColorHTML += `<button data-type="${lastFontStatus[0]}" class="protyle-font__style">${lastFontStatus[1]}</button>`;
86 }
87 break;
88 case "style1":
89 lastColorHTML += `<button class="color__square ariaLabel" data-position="3south" aria-label="${window.siyuan.languages.color}${lastFontStatus[1] ? "" : " " + window.siyuan.languages.default}" ${lastFontStatus[1] ? `style="background-color:${lastFontStatus[1]};color:${lastFontStatus[2]}"` : ""} data-type="${lastFontStatus[0]}">A</button>`;
90 break;
91 case "clear":
92 lastColorHTML += `<button style="height: 26px;display: flex;align-items: center;padding: 0 5px;" data-type="${lastFontStatus[0]}" class="protyle-font__style ariaLabel" aria-label="${window.siyuan.languages.clearFontStyle}"><svg class="svg--mid"><use xlink:href="#iconTrashcan"></use></svg></button>`;
93 break;
94 }
95 });
96 lastColorHTML += "</div>";
97 }
98 let textElement: HTMLElement;
99 let fontSize = window.siyuan.config.editor.fontSize + "px";
100 if (nodeElements && nodeElements.length > 0) {
101 textElement = nodeElements[0] as HTMLElement;
102 } else {
103 textElement = protyle.toolbar.range.cloneContents().querySelector('[data-type~="text"]') as HTMLElement;
104 if (!textElement) {
105 textElement = hasClosestByAttribute(protyle.toolbar.range.startContainer, "data-type", "text") as HTMLElement;
106 }
107 }
108 if (textElement) {
109 fontSize = textElement.style.fontSize || window.siyuan.config.editor.fontSize + "px";
110 }
111 element.innerHTML = `${lastColorHTML}
112<div class="fn__hr"></div>
113<div data-id="color">${window.siyuan.languages.color}</div>
114<div class="fn__hr--small"></div>
115<div data-id="colorWrap" class="fn__flex fn__flex-wrap">
116 <button class="color__square ariaLabel" data-position="3south" data-type="style1" aria-label="${window.siyuan.languages.default}">A</button>
117 <button class="color__square" data-type="style1" style="color: var(--b3-card-error-color);background-color: var(--b3-card-error-background);">A</button>
118 <button class="color__square" data-type="style1" style="color: var(--b3-card-warning-color);background-color: var(--b3-card-warning-background);">A</button>
119 <button class="color__square" data-type="style1" style="color: var(--b3-card-info-color);background-color: var(--b3-card-info-background);">A</button>
120 <button class="color__square" data-type="style1" style="color: var(--b3-card-success-color);background-color: var(--b3-card-success-background);">A</button>
121</div>
122<div class="fn__hr"></div>
123<div data-id="colorFont">${window.siyuan.languages.colorFont}</div>
124<div class="fn__hr--small"></div>
125<div data-id="colorFontWrap" class="fn__flex fn__flex-wrap">
126 ${colorHTML}
127</div>
128<div class="fn__hr"></div>
129<div data-id="colorPrimary">${window.siyuan.languages.colorPrimary}</div>
130<div class="fn__hr--small"></div>
131<div data-id="colorPrimaryWrap" class="fn__flex fn__flex-wrap">
132 ${bgHTML}
133</div>
134<div class="fn__hr"></div>
135<div data-id="fontStyle">${window.siyuan.languages.fontStyle}</div>
136<div class="fn__hr--small"></div>
137<div data-id="fontStyleWrap" class="fn__flex">
138 <button data-type="style2" class="protyle-font__style" style="-webkit-text-stroke: 0.2px var(--b3-theme-on-background);-webkit-text-fill-color : transparent;">${window.siyuan.languages.hollow}</button>
139 <button data-type="style4" class="protyle-font__style" style="text-shadow: 1px 1px var(--b3-theme-surface-lighter), 2px 2px var(--b3-theme-surface-lighter), 3px 3px var(--b3-theme-surface-lighter), 4px 4px var(--b3-theme-surface-lighter)">${window.siyuan.languages.shadow}</button>
140</div>
141<div class="fn__hr${disableFont ? " fn__none" : ""}"></div>
142<div data-id="fontSize" class="fn__flex${disableFont ? " fn__none" : ""}">
143 ${window.siyuan.languages.fontSize}
144 <span class="fn__flex-1"></span>
145 <label class="fn__flex">
146 ${window.siyuan.languages.relativeFontSize}
147 <span class="fn__space"></span>
148 <input class="b3-switch fn__flex-center" ${fontSize.endsWith("em") ? "checked" : ""} type="checkbox">
149 <span class="fn__space--small"></span>
150 </label>
151</div>
152<div data-id="fontSizeWrap" class="${disableFont ? " fn__none" : ""}">
153 <div class="fn__hr"></div>
154 <div class="b3-tooltips b3-tooltips__n fn__flex${fontSize.endsWith("em") ? " fn__none" : ""}" aria-label="${fontSize}">
155 <input class="b3-slider fn__block" id="fontSizePX" max="72" min="9" step="1" type="range" value="${parseInt(fontSize)}">
156 </div>
157 <div class="b3-tooltips b3-tooltips__n fn__flex${fontSize.endsWith("em") ? "" : " fn__none"}" aria-label="${parseFloat(fontSize) * 100}%">
158 <input class="b3-slider fn__block" id="fontSizeEM" max="4.5" min="0.56" step="0.01" type="range" value="${parseFloat(fontSize)}">
159 </div>
160</div>
161<div class="fn__hr--b"></div>
162<div data-id="clearFontStyle" class="fn__flex">
163 <div class="fn__space--small"></div>
164 <button class="b3-button b3-button--remove fn__block" data-type="clear">
165 <svg><use xlink:href="#iconTrashcan"></use></svg>${window.siyuan.languages.clearFontStyle}
166 </button>
167 <div class="fn__space--small"></div>
168</div>`;
169 element.addEventListener("click", function (event: Event) {
170 let target = event.target as HTMLElement;
171 while (target && !target.isEqualNode(element)) {
172 const dataType = target.getAttribute("data-type");
173 if (target.tagName === "BUTTON") {
174 if (dataType === "style1") {
175 fontEvent(protyle, nodeElements, dataType, target.style.backgroundColor + Constants.ZWSP + target.style.color);
176 } else if (dataType === "fontSize") {
177 fontEvent(protyle, nodeElements, dataType, target.textContent.trim());
178 } else if (dataType === "backgroundColor") {
179 fontEvent(protyle, nodeElements, dataType, target.style.backgroundColor);
180 } else if (dataType === "color") {
181 fontEvent(protyle, nodeElements, dataType, target.style.color);
182 } else {
183 fontEvent(protyle, nodeElements, dataType);
184 }
185 break;
186 }
187 target = target.parentElement;
188 }
189 });
190 const switchElement = element.querySelector(".b3-switch") as HTMLInputElement;
191 const fontSizePXElement = element.querySelector("#fontSizePX") as HTMLInputElement;
192 const fontSizeEMElement = element.querySelector("#fontSizeEM") as HTMLInputElement;
193 switchElement.addEventListener("change", function () {
194 if (switchElement.checked) {
195 // px -> em
196 const em = parseFloat((parseInt(fontSizePXElement.value) / 16).toFixed(2));
197 fontSizeEMElement.parentElement.setAttribute("aria-label", (em * 100).toString() + "%");
198 fontSizeEMElement.value = em.toString();
199
200 fontSizePXElement.parentElement.classList.add("fn__none");
201 fontSizeEMElement.parentElement.classList.remove("fn__none");
202 fontEvent(protyle, nodeElements, "fontSize", fontSizeEMElement.value + "em");
203 } else {
204 const px = Math.round(parseFloat(fontSizeEMElement.value) * 16);
205 fontSizePXElement.parentElement.setAttribute("aria-label", px + "px");
206 fontSizePXElement.value = px.toString();
207
208 fontSizePXElement.parentElement.classList.remove("fn__none");
209 fontSizeEMElement.parentElement.classList.add("fn__none");
210 fontEvent(protyle, nodeElements, "fontSize", fontSizePXElement.value + "px");
211 }
212 });
213 fontSizePXElement.addEventListener("change", function () {
214 fontEvent(protyle, nodeElements, "fontSize", fontSizePXElement.value + "px");
215 });
216 fontSizeEMElement.addEventListener("change", function () {
217 fontEvent(protyle, nodeElements, "fontSize", fontSizeEMElement.value + "em");
218 });
219 fontSizePXElement.addEventListener("input", function () {
220 fontSizePXElement.parentElement.setAttribute("aria-label", fontSizePXElement.value + "px");
221 });
222 fontSizeEMElement.addEventListener("input", function () {
223 fontSizeEMElement.parentElement.setAttribute("aria-label", (parseFloat(fontSizeEMElement.value) * 100).toFixed(0) + "%");
224 });
225 return element;
226};
227
228export const fontEvent = (protyle: IProtyle, nodeElements: Element[], type?: string, color?: string) => {
229 let localFontStyles = window.siyuan.storage[Constants.LOCAL_FONTSTYLES];
230 if (type) {
231 localFontStyles.splice(0, 0, `${type}${Constants.ZWSP}${color}`);
232 localFontStyles = [...new Set(localFontStyles)];
233 if (localFontStyles.length > 8) {
234 localFontStyles.splice(8, 1);
235 }
236 window.siyuan.storage[Constants.LOCAL_FONTSTYLES] = localFontStyles;
237 setStorageVal(Constants.LOCAL_FONTSTYLES, window.siyuan.storage[Constants.LOCAL_FONTSTYLES]);
238 } else {
239 if (localFontStyles.length === 0) {
240 type = "style1";
241 color = "var(--b3-card-error-color)" + Constants.ZWSP + "var(--b3-card-error-background)";
242 } else {
243 const fontStyles = localFontStyles[0].split(Constants.ZWSP);
244 type = fontStyles.splice(0, 1)[0];
245 color = fontStyles.join(Constants.ZWSP);
246 }
247 }
248 if (nodeElements && nodeElements.length > 0) {
249 updateBatchTransaction(nodeElements, protyle, (e: HTMLElement) => {
250 if (type === "clear") {
251 e.style.color = "";
252 e.style.webkitTextFillColor = "";
253 e.style.webkitTextStroke = "";
254 e.style.textShadow = "";
255 e.style.backgroundColor = "";
256 e.style.fontSize = "";
257 e.style.removeProperty("--b3-parent-background");
258 } else if (type === "style1") {
259 const colorList = color.split(Constants.ZWSP);
260 e.style.backgroundColor = colorList[0];
261 e.style.color = colorList[1];
262 e.style.setProperty("--b3-parent-background", colorList[0]);
263 } else if (type === "style2") {
264 e.style.webkitTextStroke = "0.2px var(--b3-theme-on-background)";
265 e.style.webkitTextFillColor = "transparent";
266 } else if (type === "style4") {
267 e.style.textShadow = "1px 1px var(--b3-theme-surface-lighter), 2px 2px var(--b3-theme-surface-lighter), 3px 3px var(--b3-theme-surface-lighter), 4px 4px var(--b3-theme-surface-lighter)";
268 } else if (type === "color") {
269 e.style.color = color;
270 } else if (type === "backgroundColor") {
271 e.style.backgroundColor = color;
272 e.style.setProperty("--b3-parent-background", color);
273 } else if (type === "fontSize") {
274 e.style.fontSize = color;
275 }
276 if ((type === "fontSize" || type === "clear") && e.getAttribute("data-type") === "NodeCodeBlock") {
277 lineNumberRender(e.querySelector(".hljs"));
278 }
279 });
280 focusByRange(protyle.toolbar.range);
281 } else {
282 if (type === "clear") {
283 protyle.toolbar.setInlineMark(protyle, "clear", "range", {type: "text"});
284 } else {
285 protyle.toolbar.setInlineMark(protyle, "text", "range", {type, color});
286 }
287 }
288};
289
290export const setFontStyle = (textElement: HTMLElement, textOption: ITextOption) => {
291 const setBlockRef = (blockRefOption: string) => {
292 const blockRefData = blockRefOption.split(Constants.ZWSP);
293 // 标签等元素中包含 ZWSP,需移除后拼接 https://github.com/siyuan-note/siyuan/issues/6466
294 const id = blockRefData.splice(0, 1)[0];
295 textElement.setAttribute("data-id", id);
296 textElement.setAttribute("data-subtype", blockRefData.splice(0, 1)[0]);
297 textElement.removeAttribute("data-href");
298 let text = blockRefData.join("");
299 if (text.replace(/\s/g, "") === "") {
300 text = id;
301 }
302 textElement.innerText = text;
303 };
304 const setLink = (textOption: string) => {
305 const options = textOption.split(Constants.ZWSP);
306 textElement.setAttribute("data-href", options[0]);
307 textElement.removeAttribute("data-subtype");
308 textElement.removeAttribute("data-id");
309 if (options[1]) {
310 textElement.textContent = options[1];
311 }
312 };
313 const setFileAnnotation = (textOption: string) => {
314 const options = textOption.split(Constants.ZWSP);
315 textElement.setAttribute("data-id", options[0]);
316 textElement.removeAttribute("data-href");
317 textElement.removeAttribute("data-subtype");
318 if (options[1]) {
319 textElement.textContent = options[1];
320 }
321 };
322
323 if (textOption) {
324 switch (textOption.type) {
325 case "color":
326 textElement.style.color = textOption.color;
327 break;
328 case "fontSize":
329 textElement.style.fontSize = textOption.color;
330 break;
331 case "backgroundColor":
332 textElement.style.backgroundColor = textOption.color;
333 break;
334 case "style1":
335 textElement.style.backgroundColor = textOption.color.split(Constants.ZWSP)[0];
336 textElement.style.color = textOption.color.split(Constants.ZWSP)[1];
337 break;
338 case "style2":
339 textElement.style.webkitTextStroke = "0.2px var(--b3-theme-on-background)";
340 textElement.style.webkitTextFillColor = "transparent";
341 break;
342 case "style4":
343 textElement.style.textShadow = "1px 1px var(--b3-theme-surface-lighter), 2px 2px var(--b3-theme-surface-lighter), 3px 3px var(--b3-theme-surface-lighter), 4px 4px var(--b3-theme-surface-lighter)";
344 break;
345 case "id":
346 setBlockRef(textOption.color);
347 break;
348 case "inline-math":
349 textElement.className = "render-node";
350 textElement.setAttribute("contenteditable", "false");
351 textElement.setAttribute("data-subtype", "math");
352 textElement.setAttribute("data-content", textElement.textContent.replace(Constants.ZWSP, ""));
353 textElement.removeAttribute("data-render");
354 textElement.textContent = "";
355 break;
356 case "a":
357 setLink(textOption.color);
358 break;
359 case "file-annotation-ref":
360 setFileAnnotation(textOption.color);
361 break;
362 case "inline-memo":
363 textElement.removeAttribute("contenteditable");
364 textElement.removeAttribute("data-content");
365 break;
366 }
367
368 if (!textElement.getAttribute("style")) {
369 textElement.removeAttribute("style");
370 }
371 }
372};
373
374export const hasSameTextStyle = (currentElement: HTMLElement, sideElement: HTMLElement, textObj?: ITextOption) => {
375 if (!textObj && currentElement) {
376 const types = sideElement.getAttribute("data-type").split(" ");
377 if (types.includes("inline-math") || types.includes("inline-memo") ||
378 types.includes("a")) {
379 return false;
380 }
381 if (types.includes("block-ref")) {
382 if (currentElement.getAttribute("data-id") !== sideElement.getAttribute("data-id") ||
383 currentElement.getAttribute("data-subtype") !== sideElement.getAttribute("data-subtype") ||
384 currentElement.textContent !== sideElement.textContent) {
385 return false;
386 }
387 }
388 if (types.includes("file-annotation-ref")) {
389 if (currentElement.getAttribute("data-id") !== sideElement.getAttribute("data-id") ||
390 currentElement.textContent !== sideElement.textContent) {
391 return false;
392 }
393 }
394 if (sideElement.style.color === currentElement.style.color &&
395 sideElement.style.webkitTextFillColor === currentElement.style.webkitTextFillColor &&
396 sideElement.style.webkitTextStroke === currentElement.style.webkitTextStroke &&
397 sideElement.style.textShadow === currentElement.style.textShadow &&
398 sideElement.style.backgroundColor === currentElement.style.backgroundColor &&
399 sideElement.style.fontSize === currentElement.style.fontSize) {
400 return true;
401 }
402 return false;
403 }
404
405 if (textObj) {
406 if (textObj.type === "text") {
407 // 清除样式
408 return !sideElement.style.color &&
409 !sideElement.style.webkitTextFillColor &&
410 !sideElement.style.webkitTextStroke &&
411 !sideElement.style.textShadow &&
412 !sideElement.style.fontSize &&
413 !sideElement.style.backgroundColor;
414 }
415 if (textObj.type === "color") {
416 return textObj.color === sideElement.style.color;
417 }
418 if (textObj.type === "backgroundColor") {
419 return textObj.color === sideElement.style.backgroundColor;
420 }
421 if (textObj.type === "style1") {
422 return textObj.color.split(Constants.ZWSP)[0] === sideElement.style.color &&
423 textObj.color.split(Constants.ZWSP)[1] === sideElement.style.backgroundColor;
424 }
425 if (textObj.type === "style2") {
426 return "transparent" === sideElement.style.webkitTextFillColor &&
427 "0.2px var(--b3-theme-on-background)" === sideElement.style.webkitTextStroke;
428 }
429 if (textObj.type === "style4") {
430 return "1px 1px var(--b3-theme-surface-lighter), 2px 2px var(--b3-theme-surface-lighter), 3px 3px var(--b3-theme-surface-lighter), 4px 4px var(--b3-theme-surface-lighter)" === sideElement.style.textShadow;
431 }
432 if (textObj.type === "fontSize") {
433 return textObj.color === sideElement.style.fontSize;
434 }
435 }
436 return false;
437};
438
439export const getFontNodeElements = (protyle: IProtyle) => {
440 let nodeElements: Element[];
441 if (protyle.toolbar.range.toString() === "") {
442 nodeElements = Array.from(protyle.wysiwyg.element.querySelectorAll(".protyle-wysiwyg--select"));
443 if (nodeElements.length === 0) {
444 const nodeElement = hasClosestBlock(protyle.toolbar.range.startContainer);
445 if (nodeElement) {
446 nodeElements = [nodeElement];
447 }
448 }
449 }
450 return nodeElements;
451};