A privacy-first, self-hosted, fully open source personal knowledge management software, written in typescript and golang. (PERSONAL FORK)
1import {focusByRange} from "./selection";
2import {fetchPost, fetchSyncPost} from "../../util/fetch";
3import {Constants} from "../../constants";
4/// #if !BROWSER
5import {clipboard, ipcRenderer} from "electron";
6/// #endif
7
8export const encodeBase64 = (text: string): string => {
9 if (typeof Buffer !== "undefined") {
10 return Buffer.from(text, "utf8").toString("base64");
11 } else {
12 const encoder = new TextEncoder();
13 const bytes = encoder.encode(text);
14 let binary = "";
15 const chunkSize = 0x8000; // 避免栈溢出
16
17 for (let i = 0; i < bytes.length; i += chunkSize) {
18 const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length));
19 binary += String.fromCharCode(...chunk);
20 }
21
22 return btoa(binary);
23 }
24};
25
26export const getTextSiyuanFromTextHTML = (html: string) => {
27 const siyuanMatch = html.match(/<!--data-siyuan='([^']+)'-->/);
28 let textSiyuan = "";
29 let textHtml = html;
30 if (siyuanMatch) {
31 try {
32 if (typeof Buffer !== "undefined") {
33 const decodedBytes = Buffer.from(siyuanMatch[1], "base64");
34 textSiyuan = decodedBytes.toString("utf8");
35 } else {
36 const decoder = new TextDecoder();
37 const bytes = Uint8Array.from(atob(siyuanMatch[1]), char => char.charCodeAt(0));
38 textSiyuan = decoder.decode(bytes);
39 }
40 // 移除注释节点,保持原有的 text/html 内容
41 textHtml = html.replace(/<!--data-siyuan='[^']+'-->/, "");
42 } catch (e) {
43 console.log("Failed to decode siyuan data from HTML comment:", e);
44 }
45 }
46 return {
47 textSiyuan,
48 textHtml
49 };
50};
51
52export const openByMobile = (uri: string) => {
53 if (!uri) {
54 return;
55 }
56 if (isInIOS()) {
57 if (uri.startsWith("assets/")) {
58 // iOS 16.7 之前的版本,uri 需要 encodeURIComponent
59 window.webkit.messageHandlers.openLink.postMessage(location.origin + "/assets/" + encodeURIComponent(uri.replace("assets/", "")));
60 } else if (uri.startsWith("/")) {
61 // 导出 zip 返回的是已经 encode 过的,因此不能再 encode
62 window.webkit.messageHandlers.openLink.postMessage(location.origin + uri);
63 } else {
64 try {
65 new URL(uri);
66 window.webkit.messageHandlers.openLink.postMessage(uri);
67 } catch (e) {
68 window.webkit.messageHandlers.openLink.postMessage("https://" + uri);
69 }
70 }
71 } else if (isInAndroid()) {
72 window.JSAndroid.openExternal(uri);
73 } else if (isInHarmony()) {
74 window.JSHarmony.openExternal(uri);
75 } else {
76 window.open(uri);
77 }
78};
79
80export const exportByMobile = (uri: string) => {
81 if (!uri) {
82 return;
83 }
84 if (isInIOS()) {
85 openByMobile(uri);
86 } else if (isInAndroid()) {
87 window.JSAndroid.exportByDefault(uri);
88 } else if (isInHarmony()) {
89 window.JSHarmony.exportByDefault(uri);
90 } else {
91 window.open(uri);
92 }
93};
94
95export const readText = () => {
96 if (isInAndroid()) {
97 return window.JSAndroid.readClipboard();
98 } else if (isInHarmony()) {
99 return window.JSHarmony.readClipboard();
100 }
101 if (typeof navigator.clipboard === "undefined") {
102 alert(window.siyuan.languages.clipboardPermissionDenied);
103 return "";
104 }
105 return navigator.clipboard.readText().catch(() => {
106 alert(window.siyuan.languages.clipboardPermissionDenied);
107 }) || "";
108};
109
110/// #if !BROWSER
111export const getLocalFiles = async () => {
112 // 不再支持 PC 浏览器 https://github.com/siyuan-note/siyuan/issues/7206
113 let localFiles: string[] = [];
114 if ("darwin" === window.siyuan.config.system.os) {
115 const xmlString = clipboard.read("NSFilenamesPboardType");
116 const domParser = new DOMParser();
117 const xmlDom = domParser.parseFromString(xmlString, "application/xml");
118 Array.from(xmlDom.getElementsByTagName("string")).forEach(item => {
119 localFiles.push(item.childNodes[0].nodeValue);
120 });
121 } else {
122 const xmlString = await fetchSyncPost("/api/clipboard/readFilePaths", {});
123 if (xmlString.data.length > 0) {
124 localFiles = xmlString.data;
125 }
126 }
127 return localFiles;
128};
129/// #endif
130
131export const readClipboard = async () => {
132 const text: IClipboardData = {textPlain: "", textHTML: "", siyuanHTML: ""};
133 if (isInAndroid()) {
134 text.textPlain = window.JSAndroid.readClipboard();
135 text.textHTML = window.JSAndroid.readHTMLClipboard();
136 const textObj = getTextSiyuanFromTextHTML(text.textHTML);
137 text.textHTML = textObj.textHtml;
138 text.siyuanHTML = textObj.textSiyuan;
139 return text;
140 }
141 if (isInHarmony()) {
142 text.textPlain = window.JSHarmony.readClipboard();
143 text.textHTML = window.JSHarmony.readHTMLClipboard();
144 const textObj = getTextSiyuanFromTextHTML(text.textHTML);
145 text.textHTML = textObj.textHtml;
146 text.siyuanHTML = textObj.textSiyuan;
147 return text;
148 }
149 if (typeof navigator.clipboard === "undefined") {
150 alert(window.siyuan.languages.clipboardPermissionDenied);
151 return text;
152 }
153 try {
154 const clipboardContents = await navigator.clipboard.read().catch(() => {
155 alert(window.siyuan.languages.clipboardPermissionDenied);
156 });
157 if (!clipboardContents) {
158 return text;
159 }
160 for (const item of clipboardContents) {
161 if (item.types.includes("text/html")) {
162 const blob = await item.getType("text/html");
163 text.textHTML = await blob.text();
164 const textObj = getTextSiyuanFromTextHTML(text.textHTML);
165 text.textHTML = textObj.textHtml;
166 text.siyuanHTML = textObj.textSiyuan;
167 }
168 if (item.types.includes("text/plain")) {
169 const blob = await item.getType("text/plain");
170 text.textPlain = await blob.text();
171 }
172 if (item.types.includes("image/png")) {
173 const blob = await item.getType("image/png");
174 text.files = [new File([blob], "image.png", {type: "image/png", lastModified: Date.now()})];
175 }
176 }
177 /// #if !BROWSER
178 if (!text.textHTML && !text.files) {
179 text.localFiles = await getLocalFiles();
180 }
181 /// #endif
182 return text;
183 } catch (e) {
184 return text;
185 }
186};
187
188export const writeText = (text: string) => {
189 let range: Range;
190 if (getSelection().rangeCount > 0) {
191 range = getSelection().getRangeAt(0).cloneRange();
192 }
193 try {
194 // navigator.clipboard.writeText 抛出异常不进入 catch,这里需要先处理移动端复制
195 if (isInAndroid()) {
196 window.JSAndroid.writeClipboard(text);
197 return;
198 }
199 if (isInHarmony()) {
200 window.JSHarmony.writeClipboard(text);
201 return;
202 }
203 if (isInIOS()) {
204 window.webkit.messageHandlers.setClipboard.postMessage(text);
205 return;
206 }
207 navigator.clipboard.writeText(text);
208 } catch (e) {
209 if (isInIOS()) {
210 window.webkit.messageHandlers.setClipboard.postMessage(text);
211 } else if (isInAndroid()) {
212 window.JSAndroid.writeClipboard(text);
213 } else if (isInHarmony()) {
214 window.JSHarmony.writeClipboard(text);
215 } else {
216 const textElement = document.createElement("textarea");
217 textElement.value = text;
218 textElement.style.position = "fixed"; //avoid scrolling to bottom
219 document.body.appendChild(textElement);
220 textElement.focus();
221 textElement.select();
222 document.execCommand("copy");
223 document.body.removeChild(textElement);
224 if (range) {
225 focusByRange(range);
226 }
227 }
228 }
229};
230
231export const copyPlainText = async (text: string) => {
232 text = text.replace(new RegExp(Constants.ZWSP, "g"), ""); // `复制纯文本` 时移除所有零宽空格 https://github.com/siyuan-note/siyuan/issues/6674
233 await writeText(text);
234};
235
236// 用户 iPhone 点击延迟/需要双击的处理
237export const getEventName = () => {
238 if (isIPhone()) {
239 return "touchstart";
240 } else {
241 return "click";
242 }
243};
244
245export const isOnlyMeta = (event: KeyboardEvent | MouseEvent) => {
246 if (isMac()) {
247 // mac
248 if (event.metaKey && !event.ctrlKey) {
249 return true;
250 }
251 return false;
252 } else {
253 if (!event.metaKey && event.ctrlKey) {
254 return true;
255 }
256 return false;
257 }
258};
259
260export const isNotCtrl = (event: KeyboardEvent | MouseEvent) => {
261 if (!event.metaKey && !event.ctrlKey) {
262 return true;
263 }
264 return false;
265};
266
267export const isHuawei = () => {
268 return window.siyuan.config.system.osPlatform.toLowerCase().indexOf("huawei") > -1;
269};
270
271export const isDisabledFeature = (feature: string): boolean => {
272 return window.siyuan.config.system.disabledFeatures?.indexOf(feature) > -1;
273};
274
275export const isIPhone = () => {
276 return navigator.userAgent.indexOf("iPhone") > -1;
277};
278
279export const isSafari = () => {
280 const userAgent = navigator.userAgent;
281 return userAgent.includes("Safari") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium");
282};
283
284export const isIPad = () => {
285 return navigator.userAgent.indexOf("iPad") > -1;
286};
287
288export const isMac = () => {
289 return navigator.platform.toUpperCase().indexOf("MAC") > -1;
290};
291
292export const isWin11 = async () => {
293 if (!(navigator as any).userAgentData || !(navigator as any).userAgentData.getHighEntropyValues) {
294 return false;
295 }
296 const ua = await (navigator as any).userAgentData.getHighEntropyValues(["platformVersion"]);
297 if ((navigator as any).userAgentData.platform === "Windows") {
298 if (parseInt(ua.platformVersion.split(".")[0]) >= 13) {
299 return true;
300 }
301 }
302 return false;
303};
304
305export const getScreenWidth = () => {
306 if (isInAndroid()) {
307 return window.JSAndroid.getScreenWidthPx();
308 } else if (isInHarmony()) {
309 return window.JSHarmony.getScreenWidthPx();
310 }
311 return window.outerWidth;
312};
313
314export const isWindows = () => {
315 return navigator.platform.toUpperCase().indexOf("WIN") > -1;
316};
317
318export const isInAndroid = () => {
319 return window.siyuan.config.system.container === "android" && window.JSAndroid;
320};
321
322export const isInIOS = () => {
323 return window.siyuan.config.system.container === "ios" && window.webkit?.messageHandlers;
324};
325
326export const isInHarmony = () => {
327 return window.siyuan.config.system.container === "harmony" && window.JSHarmony;
328};
329
330export const updateHotkeyAfterTip = (hotkey: string) => {
331 if (hotkey) {
332 return " " + updateHotkeyTip(hotkey);
333 }
334 return "";
335};
336
337// Mac,Windows 快捷键展示
338export const updateHotkeyTip = (hotkey: string) => {
339 if (isMac()) {
340 return hotkey;
341 }
342
343 const KEY_MAP = new Map(Object.entries({
344 "⌘": "Ctrl",
345 "⌃": "Ctrl",
346 "⇧": "Shift",
347 "⌥": "Alt",
348 "⇥": "Tab",
349 "⌫": "Backspace",
350 "⌦": "Delete",
351 "↩": "Enter",
352 }));
353
354 const keys = [];
355
356 if ((hotkey.indexOf("⌘") > -1 || hotkey.indexOf("⌃") > -1)) keys.push(KEY_MAP.get("⌘"));
357 if (hotkey.indexOf("⇧") > -1) keys.push(KEY_MAP.get("⇧"));
358 if (hotkey.indexOf("⌥") > -1) keys.push(KEY_MAP.get("⌥"));
359
360 // 不能去最后一个,需匹配 F2
361 const lastKey = hotkey.replace(/⌘|⇧|⌥|⌃/g, "");
362 if (lastKey) {
363 keys.push(KEY_MAP.get(lastKey) || lastKey);
364 }
365
366 return keys.join("+");
367};
368
369export const getLocalStorage = (cb: () => void) => {
370 fetchPost("/api/storage/getLocalStorage", undefined, (response) => {
371 window.siyuan.storage = response.data;
372 // 历史数据迁移
373 const defaultStorage: any = {};
374 defaultStorage[Constants.LOCAL_SEARCHASSET] = {
375 keys: [],
376 col: "",
377 row: "",
378 layout: 0,
379 method: 0,
380 types: {},
381 sort: 0,
382 k: "",
383 };
384 defaultStorage[Constants.LOCAL_SEARCHUNREF] = {
385 col: "",
386 row: "",
387 layout: 0,
388 };
389 Constants.SIYUAN_ASSETS_SEARCH.forEach(type => {
390 defaultStorage[Constants.LOCAL_SEARCHASSET].types[type] = true;
391 });
392 defaultStorage[Constants.LOCAL_SEARCHKEYS] = {
393 keys: [],
394 replaceKeys: [],
395 col: "",
396 row: "",
397 layout: 0,
398 colTab: "",
399 rowTab: "",
400 layoutTab: 0
401 };
402 defaultStorage[Constants.LOCAL_PDFTHEME] = {
403 light: "light",
404 dark: "dark",
405 annoColor: "var(--b3-pdf-background1)"
406 };
407 defaultStorage[Constants.LOCAL_LAYOUTS] = []; // {name: "", layout:{}, time: number, filespaths: IFilesPath[]}
408 defaultStorage[Constants.LOCAL_AI] = []; // {name: "", memo: ""}
409 defaultStorage[Constants.LOCAL_PLUGIN_DOCKS] = {}; // { pluginName: {dockId: IPluginDockTab}}
410 defaultStorage[Constants.LOCAL_PLUGINTOPUNPIN] = [];
411 defaultStorage[Constants.LOCAL_OUTLINE] = {keepCurrentExpand: false};
412 defaultStorage[Constants.LOCAL_FILEPOSITION] = {}; // {id: IScrollAttr}
413 defaultStorage[Constants.LOCAL_DIALOGPOSITION] = {}; // {id: IPosition}
414 defaultStorage[Constants.LOCAL_HISTORY] = {
415 notebookId: "%",
416 type: 0,
417 operation: "all",
418 sideWidth: "256px",
419 sideDocWidth: "256px",
420 sideDiffWidth: "256px",
421 };
422 defaultStorage[Constants.LOCAL_FLASHCARD] = {
423 fullscreen: false
424 };
425 defaultStorage[Constants.LOCAL_BAZAAR] = {
426 theme: "0",
427 template: "0",
428 icon: "0",
429 widget: "0",
430 };
431 defaultStorage[Constants.LOCAL_EXPORTWORD] = {removeAssets: false, mergeSubdocs: false};
432 defaultStorage[Constants.LOCAL_EXPORTPDF] = {
433 landscape: false,
434 marginType: "0",
435 scale: 1,
436 pageSize: "A4",
437 removeAssets: true,
438 keepFold: false,
439 mergeSubdocs: false,
440 watermark: false
441 };
442 defaultStorage[Constants.LOCAL_EXPORTIMG] = {
443 keepFold: false,
444 watermark: false
445 };
446 defaultStorage[Constants.LOCAL_DOCINFO] = {
447 id: "",
448 };
449 defaultStorage[Constants.LOCAL_IMAGES] = {
450 file: "1f4c4",
451 note: "1f5c3",
452 folder: "1f4d1"
453 };
454 defaultStorage[Constants.LOCAL_EMOJIS] = {
455 currentTab: "emoji"
456 };
457 defaultStorage[Constants.LOCAL_FONTSTYLES] = [];
458 defaultStorage[Constants.LOCAL_FILESPATHS] = []; // IFilesPath[]
459 defaultStorage[Constants.LOCAL_SEARCHDATA] = {
460 page: 1,
461 sort: 0,
462 group: 0,
463 hasReplace: false,
464 method: 0,
465 hPath: "",
466 idPath: [],
467 k: "",
468 r: "",
469 types: {
470 document: window.siyuan.config.search.document,
471 heading: window.siyuan.config.search.heading,
472 list: window.siyuan.config.search.list,
473 listItem: window.siyuan.config.search.listItem,
474 codeBlock: window.siyuan.config.search.codeBlock,
475 htmlBlock: window.siyuan.config.search.htmlBlock,
476 mathBlock: window.siyuan.config.search.mathBlock,
477 table: window.siyuan.config.search.table,
478 blockquote: window.siyuan.config.search.blockquote,
479 superBlock: window.siyuan.config.search.superBlock,
480 paragraph: window.siyuan.config.search.paragraph,
481 embedBlock: window.siyuan.config.search.embedBlock,
482 databaseBlock: window.siyuan.config.search.databaseBlock,
483 },
484 replaceTypes: Object.assign({}, Constants.SIYUAN_DEFAULT_REPLACETYPES),
485 };
486 defaultStorage[Constants.LOCAL_ZOOM] = 1;
487 defaultStorage[Constants.LOCAL_MOVE_PATH] = {keys: [], k: ""};
488 defaultStorage[Constants.LOCAL_RECENT_DOCS] = {type: "viewedAt"}; // TRecentDocsSort
489
490 [Constants.LOCAL_EXPORTIMG, Constants.LOCAL_SEARCHKEYS, Constants.LOCAL_PDFTHEME, Constants.LOCAL_BAZAAR,
491 Constants.LOCAL_EXPORTWORD, Constants.LOCAL_EXPORTPDF, Constants.LOCAL_DOCINFO, Constants.LOCAL_FONTSTYLES,
492 Constants.LOCAL_SEARCHDATA, Constants.LOCAL_ZOOM, Constants.LOCAL_LAYOUTS, Constants.LOCAL_AI,
493 Constants.LOCAL_PLUGINTOPUNPIN, Constants.LOCAL_SEARCHASSET, Constants.LOCAL_FLASHCARD,
494 Constants.LOCAL_DIALOGPOSITION, Constants.LOCAL_SEARCHUNREF, Constants.LOCAL_HISTORY,
495 Constants.LOCAL_OUTLINE, Constants.LOCAL_FILEPOSITION, Constants.LOCAL_FILESPATHS, Constants.LOCAL_IMAGES,
496 Constants.LOCAL_PLUGIN_DOCKS, Constants.LOCAL_EMOJIS, Constants.LOCAL_MOVE_PATH, Constants.LOCAL_RECENT_DOCS].forEach((key) => {
497 if (typeof response.data[key] === "string") {
498 try {
499 const parseData = JSON.parse(response.data[key]);
500 if (typeof parseData === "number") {
501 // https://github.com/siyuan-note/siyuan/issues/8852 Object.assign 会导致 number to Number
502 window.siyuan.storage[key] = parseData;
503 } else {
504 window.siyuan.storage[key] = Object.assign(defaultStorage[key], parseData);
505 }
506 } catch (e) {
507 window.siyuan.storage[key] = defaultStorage[key];
508 }
509 } else if (typeof response.data[key] === "undefined") {
510 window.siyuan.storage[key] = defaultStorage[key];
511 }
512 });
513 // 搜索数据添加 replaceTypes 兼容
514 if (!window.siyuan.storage[Constants.LOCAL_SEARCHDATA].replaceTypes ||
515 Object.keys(window.siyuan.storage[Constants.LOCAL_SEARCHDATA].replaceTypes).length === 0) {
516 window.siyuan.storage[Constants.LOCAL_SEARCHDATA].replaceTypes = Object.assign({}, Constants.SIYUAN_DEFAULT_REPLACETYPES);
517 }
518 cb();
519 });
520};
521
522export const setStorageVal = (key: string, val: any, cb?: () => void) => {
523 if (window.siyuan.config.readonly || window.siyuan.isPublish) {
524 return;
525 }
526 fetchPost("/api/storage/setLocalStorageVal", {
527 app: Constants.SIYUAN_APPID,
528 key,
529 val,
530 }, () => {
531 if (cb) {
532 cb();
533 }
534 });
535};
536
537/// #if !BROWSER
538export const initFocusFix = () => {
539 if (!isWindows()) {
540 return;
541 }
542 const originalAlert = window.alert;
543 const originalConfirm = window.confirm;
544 const fixFocusAfterDialog = () => {
545 ipcRenderer.send("siyuan-focus-fix");
546 };
547 window.alert = function (message: string) {
548 try {
549 const result = originalAlert.call(this, message);
550 fixFocusAfterDialog();
551 return result;
552 } catch (error) {
553 console.error("alert error:", error);
554 fixFocusAfterDialog();
555 return undefined;
556 }
557 };
558 window.confirm = function (message: string) {
559 try {
560 const result = originalConfirm.call(this, message);
561 fixFocusAfterDialog();
562 return result;
563 } catch (error) {
564 console.error("confirm error:", error);
565 fixFocusAfterDialog();
566 return false;
567 }
568 };
569};
570/// #endif