import type { Location, Navigator } from '@solidjs/router' import type { ArrayOrSingle } from '@wovin/core' import type { ApplogForInsert, ApplogForInsertOptionalAgent, EntityID } from '@wovin/core/applog' import type { IShare } from '@wovin/core/pubsub' import { makePersisted } from '@solid-primitives/storage' import { useLocation, useNavigate, useSearchParams } from '@solidjs/router' import { mapKeysToObject } from '@wovin/utils/types' import { Logger } from 'besonders-logger' import debounce from 'lodash-es/debounce' import { createEffect, createMemo, createSignal, onCleanup, onMount } from 'solid-js' import { editorMap } from '../components/BlockContent' import { useAgent } from '../data/agent/AgentState' import { copyToClipboard } from '../data/block-ui-helpers-paste' import { RE_SELECTOR_BLOCKS } from '../data/getShareThread' import { useRawThread } from './reactive' import 'long-press-event' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars /** Origins listed here are considered Note3 */ const KNOWN_ORIGINS = [ 'https://note3.app', 'https://dev.note3.app', ...[ ...(!window.location.pathname.startsWith('/ipfs') || !window.location.pathname.startsWith('/ipns') // HACK: to check if we're on a gateway (not subdomain-based) ? [window.location.origin] : []), ], ] export const [devMode, setDevMode] = makePersisted( createSignal(import.meta.env.DEV), { name: 'note3.dev-mode' }, ) export function svgElementToDataURL(svgEl: SVGElement): string { const svgString = new XMLSerializer().serializeToString(svgEl) return svgStringToDataURL(svgString) } export function svgStringToDataURL(svgString: string): string { const encoded = encodeURIComponent(svgString) .replace(/'/g, '%27') .replace(/"/g, '%22') const svgDataURL = `data:image/svg+xml;utf8,${encoded}` WARN({ svgDataURL }) return svgDataURL } export function focusViewOnBlock( { id, inputFocus = null, end = true }: { id: EntityID | null inputFocus?: EntityID | true end?: boolean }, { location, navigate }: LocNav, ) { // const location = useLocation() // doesn't work because not called in a component, but some callback missing context :/ // const navigate = useNavigate() const urlParams = window.location.hash.split('?', 2)[1] const parsedUrlParams = new URLSearchParams(urlParams) parsedUrlParams.delete('search') parsedUrlParams.delete('filters') DEBUG('[focusViewOnBlock]:', id, { end, location, navigate, urlParams, parsedUrlParams }) navigate( `/${id ? (`block/${id}`) : ''}${parsedUrlParams.size ? `?${parsedUrlParams}` : ''}`, ) if (inputFocus) focusBlockAsInput({ id: inputFocus === true ? id : inputFocus, end }) } // export function replacePathInUri( // location: Location, // newPath: string, // ) { // // const urlParams = location.hash.split('?', 2)[1] // // const parsedUrlParams = new URLSearchParams(urlParams) // const params = location.query // // params.delete('search') // // params.delete('filters') // DEBUG('[replacePathInUri]:', id, { end, location, navigate, urlParams, params }) // return replaceUriParts(location, { pathname: newPath }) // } export function replaceUriParts( location: Location, replace: Partial, ) { const { pathname, search, hash } = { ...location, ...replace, } return `${pathname}${search}${hash}` } export function onMountWithRefs(callback: Function) { onMount(() => { let discard = false queueMicrotask(() => !discard && callback()) onCleanup(() => discard = true) }) } export function onMountFxDeprecated(callback: Function) { // HACK: shouldn't be needed actually, use solid-js onMount return () => onMount(() => { let discard = false queueMicrotask(() => !discard && callback()) onCleanup(() => discard = true) }) } let latestFocusTarget: string = null export function EventDebugger(evt) { return DEBUG(evt) } export function focusBlockAsInput( args: { id: EntityID, end?: boolean, start?: boolean, select?: boolean, pos?: number }, ) { const { id, start = false, end = !start, select = false, pos } = args // const blkVM = useBlk(id) VERBOSE('Focusing:', id, { pos }) // HACK the whole focus debacle is dependent on successful rerendering whenever solid gets around to it... latestFocusTarget = id let tries = 0 let successes = 0 function tryFocus(args: Parameters[0]) { const { id, start = false, end = !start, select = false, pos = null } = args if (latestFocusTarget !== id) return const elem = document.getElementById(`block-${id}`) const editor = editorMap.get(elem as HTMLDivElement) DEBUG(`Focusing attempt ${tries} (${successes} successes) for`, { id, elem, pos, editor }) tries++ if (!elem) { if (tries < 10) setTimeout(tryFocus.bind(null, args), 100) } else { if (successes == 0 || elem !== document.activeElement) { editor?.commands.focus() // TODO: focus at position - https://tiptap.dev/api/commands/focus // elem.focus() // // from: https://stackoverflow.com/a/59437681/1633985 // if (elem.hasChildNodes()) { // if the element is not empty // let s = window.getSelection() // let r = document.createRange() // let e = elem.lastChild ?? elem // if (select) { // VERBOSE('select', { elem, e, s }) // r.setStart(e, 0) // r.setEnd(e, 1) // s.removeAllRanges() // s.addRange(r) // } else if (pos) { // DEBUG('set to pos', { pos, r, e }) // r.setStart(e, pos) // // r.setEnd(e, pos) // s.removeAllRanges() // s.addRange(r) // } else if (end) { // r.setStart(e, 1) // r.setEnd(e, 1) // s.removeAllRanges() // s.addRange(r) // } else if (start) { // r.setStart(e, 0) // // r.setEnd(e, 1) // s.removeAllRanges() // s.addRange(r) // } // } } successes++ if (successes < 5) setTimeout(() => tryFocus(args), 100) // HACK because db invalidation replaced DOM and focus is discarded.... FML } } tryFocus(args) } export function singleBlockOfShare(share: IShare) { const selector = share.selectors?.find(s => s.match(RE_SELECTOR_BLOCKS)) if (!selector) return null const blocksMatch = selector.match(RE_SELECTOR_BLOCKS) if (!blocksMatch) return null const blocks = blocksMatch[1].split(',') return blocks.length === 1 ? blocks[0] : null } export function makeNote3Url( { pub, block, previewPub = false, relative = false }: { pub?: string, block?: string, previewPub?: boolean, relative?: boolean }, ) { if (!pub && !block) throw new Error('Neither pub nor block - what kind of URL would that be?') const urlWithoutHash = `${window.location.origin}${window.location.pathname}` const baseUrl = relative ? '' : `${urlWithoutHash}#` // ? on which domain - window.origin vs canonical note3.app if (previewPub) { return `${baseUrl}/${block ? `block/${block}` : ''}${pub ? `?preview=${pub}` : ''}` } else { return `${baseUrl}/${block ? `block/${block}` : ''}${pub ? `?share=${pub}` : ''}` } } export function explorerUrl(cid: string) { return `https://explore.ipld.io/#/explore/${cid}` } const RE_NOTE3_URL_PATH = /\/share\/([a-zA-Z0-9]+)/ export function tryParseNote3URL(value: string) { const url = tryParseURL(value) if (!url) return null const result = {} as { share?: string preview?: boolean // root?: EntityID focus?: EntityID } if (url.protocol === 'note3:') { const match = url.pathname.match(RE_NOTE3_URL_PATH) if (match) { result.share = match[1] } } else if (url.hash && KNOWN_ORIGINS.includes(url.origin)) { // HACK: if we're on a gateway (not subdomain-based) const blockMatch = url.hash.match(/^#\/block\/([a-z0-9]+)/) if (blockMatch) { result.focus = blockMatch[1] } const params = searchParamsFromHash(url.hash) if (params?.get('share')) { result.share = params?.get('share') } else if (params?.get('pub')) { // backward compatibility: old URLs used ?pub= result.share = params?.get('pub') } else if (params?.get('preview')) { result.share = params?.get('preview') result.preview = true } } else { return null } VERBOSE('Parsed Note3 url:', value, result) return result } // (i) using actual useSearchParams is most likely metter solution // export function setParamInCurrentRouter({ location, navigate }: LocNav, params: Record) { // let urlParams = location.query // params.forEach(([key, value]) => { // urlParams.set(key, value) // }) // navigate(`${location.pathname}${searchParams.size > 0 ? `?${searchParams.toString()}` : ''}`) // } // export function setParamInCurrentUrl(params: Record) { // let urlParams = new URLSearchParams(window.location.search) // urlParams.forEach(([key, value]) => { // urlParams.set(key, value) // }) // window.location.search = urlParams.toString() // } export function replaceSearchParamsInUrl(location: Location, params: Record) { const current = new URLSearchParams(location.query as Record) Object.entries(params).forEach(([key, value]) => { if (value) { current.set(key, value) } else { current.delete(key) } }) DEBUG(`[replaceSearchParamInUrl]`, { searchParams: current, entries: [...current.entries()], params }) return `${location.pathname}${current.size > 0 ? `?${current.toString()}` : ''}` } /** @deprecated was from w3ui times */ export function searchParamsFromHash(hashPartOfUrl?: string) { const split = (hashPartOfUrl ?? window.location.hash).split('?', 2) return split.length >= 2 ? new URLSearchParams(split[1]) : null } export function useSearchParam(key: string) { const { location, navigate } = useLocationNavigate() const [params, setParams] = useSearchParams() return [ () => params[key], (newVal: string) => setParams({ [key]: newVal }), ] as const } export function useZenMode() { const [get, set] = useSearchParam('zen') return [ () => get() === 'true' || get() === '1', (newVal: boolean) => set(newVal ? 'true' : undefined), ] as const } export function tryParseURL(value: string) { try { return new URL(value.trim()) } catch (error) { return null } } export function stopPropagation(event, slotFilter?: string) { VERBOSE('StopPropagation of', { event, slotFilter }) if (!slotFilter || event.target.slot === slotFilter) { event.stopPropagation() // this will stop propagation in capturing phase } } export function createAsyncMemo(effect: () => Promise) { const [value, setValue] = createSignal() createEffect(async () => { // @ts-expect-error weird Awaited type setValue(await effect()) }) return value } export function createAsyncButtonHandler(handler: () => any | Promise) { const [loading, setLoading] = createSignal(false) return createMemo(() => ({ onclick: async (e: MouseEvent) => { setLoading(true) try { e.stopPropagation() await handler() } catch (err) { ERROR(err) notifyToast(`Error: ${err.message || JSON.stringify(err, undefined, 2)}`, 'danger') } finally { setLoading(false) } }, loading: loading(), })) } function escapeHtml(html) { const div = document.createElement('div') div.textContent = html return div.innerHTML } export const variantMap = { primary: 'i-ph:info-bold', success: 'i-ph:check-circle', neutral: 'i-ph:info-bold', warning: 'i-ph:warning-bold', danger: 'i-ph:x-circle', } type ToastVariants = keyof typeof variantMap export const ToastVariant = mapKeysToObject(variantMap) export function notifyToast( message: string, variant: ToastVariants = 'primary', duration = 5000, icon = '', iconStyles = '', iconClasses = '', ) { icon = (icon || variantMap[variant]) ?? 'i-ph:info-bold' const escapedMessage = escapeHtml(message) DEBUG({ message, escapedMessage }) iconStyles = iconStyles || `width:1.5em;height:1.5em;margin-right:1em;` // HACKY style stuff = penalty for not using sl-icon : / const alert = Object.assign(document.createElement('sl-alert'), { variant, closable: true, duration, innerHTML: `
${escapedMessage} `, // // innerHTML: ` // //
// ${escapedMessage} //
// `, // shadow dom stuff, uno won't work unless the // also https://shoelace.style/getting-started/usage#dont-use-self-closing-tags }) // TODO do this in a more solidjs way without the style shenanigans document.body.append(alert) return (alert as typeof alert & { toast: () => void }).toast() } /* from: https://stackoverflow.com/a/16348977/ */ export function stringToColor(str: string) { let hash = 0 for (var i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } let colour = '#' for (var i = 0; i < 3; i++) { const value = (hash >> (i * 8)) & 0xFF colour += (`00${value.toString(16)}`).substr(-2) } return colour } /** * isColorDark calculates the HSP value for a color, to determine its brightness. * A value of 127.5 and below means dark, and above 127.5 means bright * You can adjust this parameter to find really bright colors (maxHsp = 223) * * @param color * @param maxHsp - default 127.5 - * @returns {boolean|undefined} */ export function isColorDark(color, maxHsp = 127.5) { const rgb = rgbFromColor(color) if (!rgb) { VERBOSE(`${color} no RGB??`) return undefined } const { r, g, b } = rgb // HSP (Highly Sensitive Poo) equation from http://alienryderflex.com/hsp.html const hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)) VERBOSE(`${color} isColorDark? ${hsp}`) // Using the HSP value, determine whether the color is light or dark return hsp <= maxHsp } /** * returns either [r,g,b] from the color, or undefined if color is undefined * @param color */ export function rgbFromColor(color) { if (!color) return undefined // Variables for red, green, blue values let r, g, b // Check the format of the color, HEX or RGB? if (color.match(/^rgb/)) { // If HEX --> store the red, green, blue values in separate variables color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/) r = color[1] g = color[2] b = color[3] } else { // If RGB --> Convert it to HEX: http://gist.github.com/983661 color = +`0x${color.slice(1).replace(color.length < 5 && /./g, '$&$&')}` r = color >> 16 g = (color >> 8) & 255 b = color & 255 } return { r, g, b } } export function mergeStyles(propStyles: string | object | null, ourStyles: object) { if (propStyles === 'string') WARN(`string style cannot be merged, dropped:`, propStyles) return { ...(typeof propStyles !== 'string' ? propStyles : null), ...ourStyles, } } interface GlobalHoverSignal { type: string id?: string } export function makeGlobalHover( makeAttributes: (active: boolean, myID: boolean) => Partial>, ) { const [active, setActive] = createSignal(null) return [ (type: string) => active() && (active().id || true), // we want to be able to just truthy-check, but also get the specific value, if one is given (type: string, id?: string) => { const attrs = { onMouseEnter() { setActive({ type, id }) }, onMouseLeave() { setActive(null) }, ...makeAttributes(active()?.type === type, active()?.id && active().id === id), } return attrs }, ] as const } /** https://stackoverflow.com/a/4819886/1633985 */ export function isTouchDevice() { return (('ontouchstart' in window) || (navigator.maxTouchPoints > 0) // @ts-expect-error || (navigator.msMaxTouchPoints > 0)) } export function promptBlobDownload(blob: Blob, filename: string) { const blobUrl = URL.createObjectURL(blob) const link = document.createElement('a') link.href = blobUrl link.download = filename link.click() } export type HtmlProps = JSX.HTMLAttributes export type DivProps = HtmlProps declare module 'solid-js' { namespace JSX { interface Directives { onClickOrLongPress: (event: MouseEvent, longPress: boolean) => void copyOnClick: string } } } // export function onLongPress(element: HTMLDivElement, accessor: Accessor) { // function handlePress(e) { // e.preventDefault() // const action = accessor() // action(e) // } // element.addEventListener('contextmenu', handlePress) // onCleanup(() => element.removeEventListener('contextmenu', handlePress)) // } export function copyOnClick(element: Element, value$: Accessor) { function onClick(e: MouseEvent) { if (e.button === 0) { copyToClipboard(value$()) } } element.addEventListener('click', onClick, true) onCleanup(() => { element.removeEventListener('click', onClick, true) }) } export function onClickOrLongPress(element: Element, accessor: Accessor<(event: MouseEvent, longPress: boolean) => void>) { let pressTimer: NodeJS.Timeout | null const startPress = (e) => { // e.preventDefault() DEBUG('[onClickOrLongPress] startPress', e) clearTimeout(pressTimer) pressTimer = setTimeout(() => { pressTimer = null accessor()(e, true) }, 500) } const endPress = (e) => { DEBUG('[onClickOrLongPress] endPress', e, pressTimer) if (pressTimer) { clearTimeout(pressTimer) pressTimer = null accessor()(e, false) } } // Attach the events DEBUG('[onClickOrLongPress] attaching to', element) element.addEventListener('pointerdown', startPress, true) element.addEventListener('pointerup', endPress, true) // Ensure cleanup of event listeners and timers when the component unmounts onCleanup(() => { DEBUG('[onClickOrLongPress] detaching from', element) element.removeEventListener('pointerdown', startPress, true) element.removeEventListener('pointerup', endPress, true) clearTimeout(pressTimer) // Clear any pending timer }) } export interface LocNav { navigate: Navigator location: Location } export function useLocationNavigate(): LocNav & { locnav: LocNav } { const navigate = useNavigate() const location = useLocation() const locnav = { location, navigate } return { locnav, location, navigate } } export function useLogWriter() { const writeThread = useRawThread() const agent = useAgent() return (logOrLogs: ArrayOrSingle) => { return writeThread.insert( arrayIfSingle(logOrLogs).map((log) => { if (!log.ag) log.ag = agent.ag return log as ApplogForInsert }), ) } } export function useSingleUrlParam(param: string) { const [urlParams, setUrlParams] = useSearchParams() return [ createMemo(() => { const val = urlParams[param] if (Array.isArray(val)) { if (val.length > 1) throw ERROR(`Multiple parameter values passed for '${param}:'`, val) return val[0] } return val }), (val: T) => setUrlParams({ [param]: val }), ] as const } /** will only update if eqVal still the same after debounce */ export function createDebouncedWithEqCheck(equalityValGetter: () => EQ, updater: (newVal: V) => void, time: number) { let lastTriggerBasedOn: EQ const debounced = debounce((newVal: V) => { if (equalityValGetter() !== lastTriggerBasedOn) { DEBUG(`[createDebounceWithEqCheck] skipping, because not equal:`, { fromGetter: equalityValGetter(), lastTriggerBasedOn }) } else { DEBUG(`[createDebounceWithEqCheck] equal, setting newVal:`, { fromGetter: equalityValGetter(), lastTriggerBasedOn, newVal }) updater(newVal) lastTriggerBasedOn = undefined } }, time) const debouncedWrapped: typeof debounced = (newVal: V) => { lastTriggerBasedOn = equalityValGetter() debounced(newVal) } debouncedWrapped.flush = () => { DEBUG(`[createDebounceWithEqCheck] flushing:`, debounced) debounced.flush() } debouncedWrapped.cancel = () => { DEBUG(`[createDebounceWithEqCheck] canceling:`, debounced) debounced.cancel() } return debouncedWrapped }