mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
chore: Enhancement + New Feature (#185)
* wip * wip page * chore: style * wip pages * wip pages * chore: toggle * chore: link * feat: topic search * chore: page section * refactor: apply tailwind class ordering * fix: handle loggedIn user for guest route * feat: folder & image schema * chore: move utils to shared * refactor: tailwind class ordering * feat: img ext for editor * refactor: remove qa * fix: tanstack start * fix: wrong import * chore: use toast * chore: schema
This commit is contained in:
187
web/shared/editor/lib/utils/index.ts
Normal file
187
web/shared/editor/lib/utils/index.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { LaEditorProps } from "@shared/editor"
|
||||
import { Editor } from "@tiptap/core"
|
||||
|
||||
export function getOutput(editor: Editor, format: LaEditorProps["output"]) {
|
||||
if (format === "json") {
|
||||
return editor.getJSON()
|
||||
}
|
||||
|
||||
if (format === "html") {
|
||||
return editor.getText() ? editor.getHTML() : ""
|
||||
}
|
||||
|
||||
return editor.getText()
|
||||
}
|
||||
|
||||
export type FileError = {
|
||||
file: File | string
|
||||
reason: "type" | "size" | "invalidBase64" | "base64NotAllowed"
|
||||
}
|
||||
|
||||
export type FileValidationOptions = {
|
||||
allowedMimeTypes: string[]
|
||||
maxFileSize?: number
|
||||
allowBase64: boolean
|
||||
}
|
||||
|
||||
type FileInput = File | { src: string | File; alt?: string; title?: string }
|
||||
|
||||
// URL validation and sanitization
|
||||
export const isUrl = (
|
||||
text: string,
|
||||
options?: { requireHostname: boolean; allowBase64?: boolean },
|
||||
): boolean => {
|
||||
if (text.match(/\n/)) return false
|
||||
|
||||
try {
|
||||
const url = new URL(text)
|
||||
const blockedProtocols = [
|
||||
"javascript:",
|
||||
"file:",
|
||||
"vbscript:",
|
||||
...(options?.allowBase64 ? [] : ["data:"]),
|
||||
]
|
||||
|
||||
if (blockedProtocols.includes(url.protocol)) return false
|
||||
if (options?.allowBase64 && url.protocol === "data:")
|
||||
return /^data:image\/[a-z]+;base64,/.test(text)
|
||||
if (url.hostname) return true
|
||||
|
||||
return (
|
||||
url.protocol !== "" &&
|
||||
(url.pathname.startsWith("//") || url.pathname.startsWith("http")) &&
|
||||
!options?.requireHostname
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const sanitizeUrl = (
|
||||
url: string | null | undefined,
|
||||
options?: { allowBase64?: boolean },
|
||||
): string | undefined => {
|
||||
if (!url) return undefined
|
||||
|
||||
if (options?.allowBase64 && url.startsWith("data:image")) {
|
||||
return isUrl(url, { requireHostname: false, allowBase64: true })
|
||||
? url
|
||||
: undefined
|
||||
}
|
||||
|
||||
const isValidUrl = isUrl(url, {
|
||||
requireHostname: false,
|
||||
allowBase64: options?.allowBase64,
|
||||
})
|
||||
const isSpecialProtocol = /^(\/|#|mailto:|sms:|fax:|tel:)/.test(url)
|
||||
|
||||
return isValidUrl || isSpecialProtocol ? url : `https://${url}`
|
||||
}
|
||||
|
||||
// File handling
|
||||
export async function blobUrlToBase64(blobUrl: string): Promise<string> {
|
||||
const response = await fetch(blobUrl)
|
||||
const blob = await response.blob()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === "string") {
|
||||
resolve(reader.result)
|
||||
} else {
|
||||
reject(new Error("Failed to convert Blob to base64"))
|
||||
}
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
const validateFileOrBase64 = <T extends FileInput>(
|
||||
input: File | string,
|
||||
options: FileValidationOptions,
|
||||
originalFile: T,
|
||||
validFiles: T[],
|
||||
errors: FileError[],
|
||||
) => {
|
||||
const { isValidType, isValidSize } = checkTypeAndSize(input, options)
|
||||
|
||||
if (isValidType && isValidSize) {
|
||||
validFiles.push(originalFile)
|
||||
} else {
|
||||
if (!isValidType) errors.push({ file: input, reason: "type" })
|
||||
if (!isValidSize) errors.push({ file: input, reason: "size" })
|
||||
}
|
||||
}
|
||||
|
||||
const checkTypeAndSize = (
|
||||
input: File | string,
|
||||
{ allowedMimeTypes, maxFileSize }: FileValidationOptions,
|
||||
) => {
|
||||
const mimeType = input instanceof File ? input.type : base64MimeType(input)
|
||||
const size =
|
||||
input instanceof File ? input.size : atob(input.split(",")[1]).length
|
||||
|
||||
const isValidType =
|
||||
allowedMimeTypes.length === 0 ||
|
||||
allowedMimeTypes.includes(mimeType) ||
|
||||
allowedMimeTypes.includes(`${mimeType.split("/")[0]}/*`)
|
||||
|
||||
const isValidSize = !maxFileSize || size <= maxFileSize
|
||||
|
||||
return { isValidType, isValidSize }
|
||||
}
|
||||
|
||||
const base64MimeType = (encoded: string): string => {
|
||||
const result = encoded.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/)
|
||||
return result && result.length ? result[1] : "unknown"
|
||||
}
|
||||
|
||||
const isBase64 = (str: string): boolean => {
|
||||
if (str.startsWith("data:")) {
|
||||
const matches = str.match(/^data:[^;]+;base64,(.+)$/)
|
||||
if (matches && matches[1]) {
|
||||
str = matches[1]
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
atob(str)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const filterFiles = <T extends FileInput>(
|
||||
files: T[],
|
||||
options: FileValidationOptions,
|
||||
): [T[], FileError[]] => {
|
||||
const validFiles: T[] = []
|
||||
const errors: FileError[] = []
|
||||
|
||||
files.forEach((file) => {
|
||||
const actualFile = "src" in file ? file.src : file
|
||||
|
||||
if (actualFile instanceof File) {
|
||||
validateFileOrBase64(actualFile, options, file, validFiles, errors)
|
||||
} else if (typeof actualFile === "string") {
|
||||
if (isBase64(actualFile)) {
|
||||
if (options.allowBase64) {
|
||||
validateFileOrBase64(actualFile, options, file, validFiles, errors)
|
||||
} else {
|
||||
errors.push({ file: actualFile, reason: "base64NotAllowed" })
|
||||
}
|
||||
} else {
|
||||
validFiles.push(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return [validFiles, errors]
|
||||
}
|
||||
|
||||
export * from "./isCustomNodeSelected"
|
||||
export * from "./isTextSelected"
|
||||
37
web/shared/editor/lib/utils/isCustomNodeSelected.ts
Normal file
37
web/shared/editor/lib/utils/isCustomNodeSelected.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { HorizontalRule } from "../../extensions/horizontal-rule"
|
||||
import { Link } from "../../extensions/link"
|
||||
import { Editor } from "@tiptap/react"
|
||||
|
||||
export const isTableGripSelected = (node: HTMLElement) => {
|
||||
let container = node
|
||||
|
||||
while (container && !["TD", "TH"].includes(container.tagName)) {
|
||||
container = container.parentElement!
|
||||
}
|
||||
|
||||
const gripColumn =
|
||||
container &&
|
||||
container.querySelector &&
|
||||
container.querySelector("a.grip-column.selected")
|
||||
const gripRow =
|
||||
container &&
|
||||
container.querySelector &&
|
||||
container.querySelector("a.grip-row.selected")
|
||||
|
||||
if (gripColumn || gripRow) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export const isCustomNodeSelected = (editor: Editor, node: HTMLElement) => {
|
||||
const customNodes = [HorizontalRule.name, Link.name]
|
||||
|
||||
return (
|
||||
customNodes.some((type) => editor.isActive(type)) ||
|
||||
isTableGripSelected(node)
|
||||
)
|
||||
}
|
||||
|
||||
export default isCustomNodeSelected
|
||||
26
web/shared/editor/lib/utils/isTextSelected.ts
Normal file
26
web/shared/editor/lib/utils/isTextSelected.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { isTextSelection } from "@tiptap/core"
|
||||
import { Editor } from "@tiptap/react"
|
||||
|
||||
export const isTextSelected = ({ editor }: { editor: Editor }) => {
|
||||
const {
|
||||
state: {
|
||||
doc,
|
||||
selection,
|
||||
selection: { empty, from, to },
|
||||
},
|
||||
} = editor
|
||||
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock =
|
||||
!doc.textBetween(from, to).length && isTextSelection(selection)
|
||||
|
||||
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export default isTextSelected
|
||||
Reference in New Issue
Block a user