Something went wrong. Try again.
Automerge backed Markdown editor for the Atmosphere
Something went wrong. Try again.
7.4 kB · 170 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171import { useCallback, useEffect, useRef, useState, type FormEvent, type RefObject } from 'react'import { createPortal } from 'react-dom'import { Plus } from 'lucide-react'import type { App } from '../../plugin/app'import { createTypeSchemaDraft, serializeTypeSchema } from '../../types-records/type-schema'import { isTypeName } from '../../types-records/type-name'import './root-type-creation.css'
function validationError(name: string): string | null { if (!name) return 'Enter a Type name.' if (!isTypeName(name)) return 'Use a lowercase name starting with a letter; letters, numbers, hyphens, and underscores are allowed.' return null}
function duplicateError(name: string): string { return `A Type named "${name}" already exists.`}
async function createRootType(app: App, name: string) { await app.vault.createFolder('types') const path = `types/${name}.json` if (app.vault.getAbstractFileByPath(path)) throw new Error(duplicateError(name))
let file try { file = await app.vault.create(path, serializeTypeSchema(createTypeSchemaDraft(name))) } catch (error) { // Vault.create is the atomic boundary that also rejects a concurrent creator. if (error instanceof Error && /already exists/i.test(error.message)) throw Object.assign(new Error(duplicateError(name)), { cause: error }) throw error } return file}
export function RootTypeCreationAction({ app }: { readonly app: App }) { const [isOpen, setIsOpen] = useState(false) const triggerRef = useRef<HTMLButtonElement>(null) return <> <button ref={triggerRef} className="root-type-creation-trigger" type="button" aria-label="Create new Type" title="New Type" onClick={() => setIsOpen(true)}> <Plus size={16} aria-hidden="true" /> </button> <RootTypeCreationModal app={app} isOpen={isOpen} triggerRef={triggerRef} onClose={() => setIsOpen(false)} /> </>}
function RootTypeCreationModal({ app, isOpen, triggerRef, onClose }: { app: App; isOpen: boolean; triggerRef: RefObject<HTMLButtonElement>; onClose: () => void }) { const [name, setName] = useState('') const [error, setError] = useState<string | null>(null) const [isSubmitting, setIsSubmitting] = useState(false) const [created, setCreated] = useState(false) const inputRef = useRef<HTMLInputElement>(null) const dialogRef = useRef<HTMLDivElement>(null) const mountedRef = useRef(true) const submittingRef = useRef(false) const generationRef = useRef(0) const wasOpenRef = useRef(false) const trimmedName = name.trim() const inlineError = validationError(trimmedName) const visibleError = trimmedName ? (error ?? inlineError) : error const requestClose = useCallback(() => { if (!submittingRef.current) onClose() }, [onClose])
useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false; generationRef.current += 1 } }, [])
useEffect(() => { if (!isOpen) { setName('') setError(null) setIsSubmitting(false) setCreated(false) submittingRef.current = false if (wasOpenRef.current) { wasOpenRef.current = false triggerRef.current?.focus() } return } wasOpenRef.current = true const generation = ++generationRef.current const timer = setTimeout(() => inputRef.current?.focus(), 0) return () => { clearTimeout(timer); if (generation === generationRef.current) generationRef.current += 1 } }, [isOpen, triggerRef])
useEffect(() => { if (!isOpen) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault() event.stopPropagation() requestClose() return } if (event.key !== 'Tab' || !dialogRef.current) return const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled)')) if (!focusable.length) { event.preventDefault(); dialogRef.current.focus(); return } const first = focusable[0]! const last = focusable[focusable.length - 1]! if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() } } document.addEventListener('keydown', onKeyDown) return () => document.removeEventListener('keydown', onKeyDown) }, [isOpen, requestClose])
if (!isOpen) return null
// eslint-disable-next-line sonarjs/cognitive-complexity const submit = async (event: FormEvent) => { event.preventDefault() if (submittingRef.current || inlineError || created) { setError(inlineError); return } const generation = generationRef.current submittingRef.current = true setIsSubmitting(true) setError(null) try { const file = await createRootType(app, trimmedName) if (!mountedRef.current || generation !== generationRef.current) return setCreated(true) try { await app.workspace.openFile(file) } catch (reason) { if (mountedRef.current && generation === generationRef.current) setError(`Type created, but it could not be opened: ${reason instanceof Error ? reason.message : 'unknown error'}`) return } if (mountedRef.current && generation === generationRef.current) onClose() } catch (reason) { if (mountedRef.current && generation === generationRef.current) setError(reason instanceof Error ? reason.message : 'Could not create Type.') } finally { if (mountedRef.current && generation === generationRef.current) { setIsSubmitting(false) submittingRef.current = false } } }
let submitLabel = 'Create Type' if (isSubmitting) submitLabel = 'Creating...' else if (created) submitLabel = 'Type created'
return createPortal( <div className="root-type-modal__backdrop" data-testid="root-type-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) requestClose() }}> <div ref={dialogRef} className="root-type-modal" role="dialog" tabIndex={-1} aria-modal="true" aria-labelledby="root-type-modal-title" onClick={event => event.stopPropagation()}> <h2 id="root-type-modal-title">New Type</h2> <form onSubmit={event => { void submit(event) }}> <label htmlFor="root-type-name">Type name</label> <input ref={inputRef} id="root-type-name" type="text" value={name} placeholder="project" onChange={event => { setName(event.target.value); setError(null) }} disabled={isSubmitting} aria-invalid={Boolean(visibleError)} aria-describedby={visibleError ? 'root-type-name-error' : undefined} /> <p id="root-type-name-hint">Lowercase, starting with a letter. Use letters, numbers, hyphens, or underscores.</p> {visibleError ? <div id="root-type-name-error" role="alert" aria-live="polite">{visibleError}</div> : null} <div className="root-type-modal__buttons"> <button type="button" onClick={requestClose} disabled={isSubmitting}>Cancel</button> <button type="submit" disabled={!trimmedName || Boolean(inlineError) || isSubmitting || created}>{submitLabel}</button> </div> </form> </div> </div>, document.body, )}