mirror of
https://github.com/marktext/marktext.git
synced 2025-05-02 15:52:17 +08:00
415 lines
10 KiB
JavaScript
415 lines
10 KiB
JavaScript
import runSanitize from './dompurify'
|
|
import { URL_REG, DATA_URL_REG, IMAGE_EXT_REG } from '../config'
|
|
|
|
const ID_PREFIX = 'ag-'
|
|
let id = 0
|
|
|
|
const TIMEOUT = 1500
|
|
|
|
const HTML_TAG_REPLACEMENTS = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": '''
|
|
}
|
|
|
|
export const getUniqueId = () => `${ID_PREFIX}${id++}`
|
|
|
|
export const getLongUniqueId = () => `${getUniqueId()}-${(+new Date()).toString(32)}`
|
|
|
|
export const isMetaKey = ({ key }) => key === 'Shift' || key === 'Control' || key === 'Alt' || key === 'Meta'
|
|
|
|
export const noop = () => {}
|
|
|
|
export const identity = i => i
|
|
|
|
export const isOdd = number => Math.abs(number) % 2 === 1
|
|
|
|
export const isEven = number => Math.abs(number) % 2 === 0
|
|
|
|
export const isLengthEven = (str = '') => str.length % 2 === 0
|
|
|
|
export const snakeToCamel = name => name.replace(/_([a-z])/g, (p0, p1) => p1.toUpperCase())
|
|
|
|
export const camelToSnake = name => name.replace(/([A-Z])/g, (_, p) => `-${p.toLowerCase()}`)
|
|
/**
|
|
* Are two arrays have intersection
|
|
*/
|
|
export const conflict = (arr1, arr2) => {
|
|
return !(arr1[1] < arr2[0] || arr2[1] < arr1[0])
|
|
}
|
|
|
|
export const union = ({ start: tStart, end: tEnd }, { start: lStart, end: lEnd, active }) => {
|
|
if (!(tEnd <= lStart || lEnd <= tStart)) {
|
|
if (lStart < tStart) {
|
|
return {
|
|
start: tStart,
|
|
end: tEnd < lEnd ? tEnd : lEnd,
|
|
active
|
|
}
|
|
} else {
|
|
return {
|
|
start: lStart,
|
|
end: tEnd < lEnd ? tEnd : lEnd,
|
|
active
|
|
}
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
// https://github.com/jashkenas/underscore
|
|
export const throttle = (func, wait = 50) => {
|
|
let context
|
|
let args
|
|
let result
|
|
let timeout = null
|
|
let previous = 0
|
|
const later = () => {
|
|
previous = Date.now()
|
|
timeout = null
|
|
result = func.apply(context, args)
|
|
if (!timeout) {
|
|
context = args = null
|
|
}
|
|
}
|
|
|
|
return function () {
|
|
const now = Date.now()
|
|
const remaining = wait - (now - previous)
|
|
|
|
context = this
|
|
args = arguments
|
|
if (remaining <= 0 || remaining > wait) {
|
|
if (timeout) {
|
|
clearTimeout(timeout)
|
|
timeout = null
|
|
}
|
|
previous = now
|
|
result = func.apply(context, args)
|
|
if (!timeout) {
|
|
context = args = null
|
|
}
|
|
} else if (!timeout) {
|
|
timeout = setTimeout(later, remaining)
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
// simple implementation...
|
|
export const debounce = (func, wait = 50) => {
|
|
let timer = null
|
|
return function (...args) {
|
|
if (timer) clearTimeout(timer)
|
|
timer = setTimeout(() => {
|
|
func(...args)
|
|
}, wait)
|
|
}
|
|
}
|
|
|
|
export const deepCopyArray = array => {
|
|
const result = []
|
|
const len = array.length
|
|
let i
|
|
for (i = 0; i < len; i++) {
|
|
if (typeof array[i] === 'object' && array[i] !== null) {
|
|
if (Array.isArray(array[i])) {
|
|
result.push(deepCopyArray(array[i]))
|
|
} else {
|
|
result.push(deepCopy(array[i]))
|
|
}
|
|
} else {
|
|
result.push(array[i])
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// TODO: @jocs rewrite deepCopy
|
|
export const deepCopy = object => {
|
|
const obj = {}
|
|
Object.keys(object).forEach(key => {
|
|
if (typeof object[key] === 'object' && object[key] !== null) {
|
|
if (Array.isArray(object[key])) {
|
|
obj[key] = deepCopyArray(object[key])
|
|
} else {
|
|
obj[key] = deepCopy(object[key])
|
|
}
|
|
} else {
|
|
obj[key] = object[key]
|
|
}
|
|
})
|
|
return obj
|
|
}
|
|
|
|
export const loadImage = async (url, detectContentType = false) => {
|
|
if (detectContentType) {
|
|
const isImage = await checkImageContentType(url)
|
|
if (!isImage) throw new Error('not an image')
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const image = new Image()
|
|
image.onload = () => {
|
|
resolve({
|
|
url,
|
|
width: image.width,
|
|
height: image.height
|
|
})
|
|
}
|
|
image.onerror = err => {
|
|
reject(err)
|
|
}
|
|
image.src = url
|
|
})
|
|
}
|
|
|
|
export const isOnline = () => {
|
|
return navigator.onLine === true
|
|
}
|
|
|
|
export const getPageTitle = url => {
|
|
// No need to request the title when it's not url.
|
|
if (!url.startsWith('http')) {
|
|
return ''
|
|
}
|
|
// No need to request the title when off line.
|
|
if (!isOnline()) {
|
|
return ''
|
|
}
|
|
|
|
const req = new XMLHttpRequest()
|
|
let settle
|
|
const promise = new Promise((resolve, reject) => {
|
|
settle = resolve
|
|
})
|
|
const handler = () => {
|
|
if (req.readyState === XMLHttpRequest.DONE) {
|
|
if (req.status === 200) {
|
|
const contentType = req.getResponseHeader('Content-Type')
|
|
if (/text\/html/.test(contentType)) {
|
|
const { response } = req
|
|
if (typeof response === 'string') {
|
|
const match = response.match(/<title>(.*)<\/title>/)
|
|
return match && match[1] ? settle(match[1]) : settle('')
|
|
}
|
|
return settle('')
|
|
}
|
|
return settle('')
|
|
} else {
|
|
return settle('')
|
|
}
|
|
}
|
|
}
|
|
const handleError = (e) => {
|
|
settle('')
|
|
}
|
|
req.open('GET', url)
|
|
req.onreadystatechange = handler
|
|
req.onerror = handleError
|
|
req.send()
|
|
|
|
// Resolve empty string when `TIMEOUT` passed.
|
|
const timer = new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
resolve('')
|
|
}, TIMEOUT)
|
|
})
|
|
|
|
return Promise.race([promise, timer])
|
|
}
|
|
|
|
export const checkImageContentType = url => {
|
|
const req = new XMLHttpRequest()
|
|
let settle
|
|
const promise = new Promise((resolve, reject) => {
|
|
settle = resolve
|
|
})
|
|
const handler = () => {
|
|
if (req.readyState === XMLHttpRequest.DONE) {
|
|
if (req.status === 200) {
|
|
const contentType = req.getResponseHeader('Content-Type')
|
|
if (/^image\/(?:jpeg|png|gif|svg\+xml|webp)$/.test(contentType)) {
|
|
settle(true)
|
|
} else {
|
|
settle(false)
|
|
}
|
|
} else if (req.status === 405) { // status 405 means method not allowed, and just return true.(Solve issue#1297)
|
|
settle(true)
|
|
} else {
|
|
settle(false)
|
|
}
|
|
}
|
|
}
|
|
const handleError = () => {
|
|
settle(false)
|
|
}
|
|
req.open('HEAD', url)
|
|
req.onreadystatechange = handler
|
|
req.onerror = handleError
|
|
req.send()
|
|
|
|
return promise
|
|
}
|
|
|
|
/**
|
|
* Return image information and correct the relative image path if needed.
|
|
*
|
|
* @param {string} src Image url
|
|
* @param {string} baseUrl Base path; used on desktop to fix the relative image path.
|
|
*/
|
|
export const getImageInfo = (src, baseUrl = window.DIRNAME) => {
|
|
const imageExtension = IMAGE_EXT_REG.test(src)
|
|
const isUrl = URL_REG.test(src) || (imageExtension && /^file:\/\/.+/.test(src))
|
|
|
|
// Treat an URL with valid extension as image.
|
|
if (imageExtension) {
|
|
// NOTE: Check both "C:\" and "C:/" because we're using "file:///C:/".
|
|
const isAbsoluteLocal = /^(?:\/|\\\\|[a-zA-Z]:\\|[a-zA-Z]:\/).+/.test(src)
|
|
|
|
if (isUrl || (!isAbsoluteLocal && !baseUrl)) {
|
|
if (!isUrl && !baseUrl) {
|
|
console.warn('"baseUrl" is not defined!')
|
|
}
|
|
|
|
return {
|
|
isUnknownType: false,
|
|
src
|
|
}
|
|
} else {
|
|
// Correct relative path on desktop. If we resolve a absolute path "path.resolve" doesn't do anything.
|
|
// NOTE: We don't need to convert Windows styled path to UNIX style because Chromium handels this internal.
|
|
return {
|
|
isUnknownType: false,
|
|
src: 'file://' + require('path').resolve(baseUrl, src)
|
|
}
|
|
}
|
|
} else if (isUrl && !imageExtension) {
|
|
// Assume it's a valid image and make a http request later
|
|
return {
|
|
isUnknownType: true,
|
|
src
|
|
}
|
|
}
|
|
|
|
// Data url
|
|
if (DATA_URL_REG.test(src)) {
|
|
return {
|
|
isUnknownType: false,
|
|
src
|
|
}
|
|
}
|
|
|
|
// Url type is unknown
|
|
return {
|
|
isUnknownType: false,
|
|
src: ''
|
|
}
|
|
}
|
|
|
|
export const escapeHtml = html => {
|
|
return html
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''')
|
|
}
|
|
|
|
export const unescapeHtml = text => {
|
|
return text
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, '\'')
|
|
}
|
|
|
|
export const escapeInBlockHtml = html => {
|
|
return html
|
|
.replace(/(<(style|script|title)[^<>]*>)([\s\S]*?)(<\/\2>)/g, (m, p1, p2, p3, p4) => {
|
|
return `${escapeHtml(p1)}${p3}${escapeHtml(p4)}`
|
|
})
|
|
}
|
|
|
|
export const escapeHtmlTags = html => {
|
|
return html.replace(/[&<>"']/g, x => { return HTML_TAG_REPLACEMENTS[x] })
|
|
}
|
|
|
|
export const wordCount = markdown => {
|
|
const paragraph = markdown.split(/\n{2,}/).filter(line => line).length
|
|
let word = 0
|
|
let character = 0
|
|
let all = 0
|
|
|
|
const removedChinese = markdown.replace(/[\u4e00-\u9fa5]/g, '')
|
|
const tokens = removedChinese.split(/[\s\n]+/).filter(t => t)
|
|
const chineseWordLength = markdown.length - removedChinese.length
|
|
word += chineseWordLength + tokens.length
|
|
character += tokens.reduce((acc, t) => acc + t.length, 0) + chineseWordLength
|
|
all += markdown.length
|
|
|
|
return { word, paragraph, character, all }
|
|
}
|
|
|
|
/**
|
|
* [genUpper2LowerKeyHash generate constants map hash, the value is lowercase of the key,
|
|
* also translate `_` to `-`]
|
|
*/
|
|
export const genUpper2LowerKeyHash = keys => {
|
|
return keys.reduce((acc, key) => {
|
|
const value = key.toLowerCase().replace(/_/g, '-')
|
|
return Object.assign(acc, { [key]: value })
|
|
}, {})
|
|
}
|
|
|
|
/**
|
|
* generate constants map, the value is the key.
|
|
*/
|
|
export const generateKeyHash = keys => {
|
|
return keys.reduce((acc, key) => {
|
|
return Object.assign(acc, { [key]: key })
|
|
}, {})
|
|
}
|
|
|
|
// mixins
|
|
export const mixins = (constructor, ...object) => {
|
|
return Object.assign(constructor.prototype, ...object)
|
|
}
|
|
|
|
export const sanitize = (html, purifyOptions, disableHtml) => {
|
|
if (disableHtml) {
|
|
return runSanitize(escapeHtmlTags(html), purifyOptions)
|
|
} else {
|
|
return runSanitize(escapeInBlockHtml(html), purifyOptions)
|
|
}
|
|
}
|
|
|
|
export const getParagraphReference = (ele, id) => {
|
|
const { x, y, left, top, bottom, height } = ele.getBoundingClientRect()
|
|
return {
|
|
getBoundingClientRect () {
|
|
return { x, y, left, top, bottom, height, width: 0, right: left }
|
|
},
|
|
clientWidth: 0,
|
|
clientHeight: height,
|
|
id
|
|
}
|
|
}
|
|
|
|
export const verticalPositionInRect = (event, rect) => {
|
|
const { clientY } = event
|
|
const { top, height } = rect
|
|
return (clientY - top) > (height / 2) ? 'down' : 'up'
|
|
}
|
|
|
|
export const collectFootnotes = (blocks) => {
|
|
const map = new Map()
|
|
for (const block of blocks) {
|
|
if (block.type === 'figure' && block.functionType === 'footnote') {
|
|
const identifier = block.children[0].text
|
|
map.set(identifier, block)
|
|
}
|
|
}
|
|
|
|
return map
|
|
}
|