mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
optimise doc icon loading
This commit is contained in:
parent
3d07487e76
commit
4e7e2595d4
|
|
@ -116,7 +116,7 @@ Form validation (vee-validate) uses its own internal messages — see [README.md
|
|||
|
||||
- **shadcn-vue** components live in [app/components/shadcn/ui/](app/components/shadcn/ui/) with prefix `ShadcnUi`. Configured in [components.json](components.json).
|
||||
- **Tailwind v4** via `@tailwindcss/vite`, theme in [app/assets/css/main.css](app/assets/css/main.css).
|
||||
- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix.
|
||||
- Icons via `@nuxt/icon` (CSS mode). The client bundle holds the icons the scanner finds in app code, plus — on the desktop build only — the document title icons listed by `selectableIconList()` in [app/utils/icon.ts](app/utils/icon.ts). The web build resolves those on demand through the server so they stay off the startup path. The picker itself does not go through `<Icon>`: [modules/icon-picker-css.ts](modules/icon-picker-css.ts) generates one stylesheet with a rule per selectable icon, served as `virtual:icon-picker.css` and imported by the picker provider once the page's load event has fired. Custom SVGs live in [app/assets/custom-icons/](app/assets/custom-icons/) and are reachable under the `custom-icons:` prefix.
|
||||
- **Open a11y bug: dialogs have no `DialogDescription`.** Every modal renders its description as a plain `<p>`, so the `aria-describedby` reka-ui puts on `DialogContent` points at nothing — screen readers get no description, and reka-ui warns once per mount. **This is scheduled to be fixed, not accepted.** The warning is suppressed in [vitest.config.ts](vitest.config.ts)'s `onConsoleLog` purely to keep test output readable in the meantime; that branch comes out with the fix, and the warning is still visible in `nuxt dev`. Note that routing the text through `ShadcnUiDialogDescription` is *not* a drop-in fix: the wrapper runs its class through `cn()`, and default tailwind-merge reads the custom `text-2sm` as a colour utility and drops it in favour of the wrapper's `text-sm`. Closing this needs either reka-ui's `DialogDescription` imported directly (no `cn()`) or `extendTailwindMerge` teaching `cn()` the custom text scale.
|
||||
|
||||
## Formatting & TS
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ vi.mock("vue-virtual-scroller", async () => {
|
|||
|
||||
// the provider installs the icon-picker context that its children reach
|
||||
// through useIconPicker, so an IconPicker inside the slot is what opens
|
||||
// it. Each icon in the list carries a tooltip, whose context the app
|
||||
// installs once at page level.
|
||||
// it. The grid's shared tooltip needs the context the app installs once
|
||||
// at page level.
|
||||
function mountProvider() {
|
||||
return mountUnderTooltipProvider(IconPickerProvider, {
|
||||
slots: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom"
|
||||
import { buttonVariants } from "~/components/shadcn/ui/button"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
// With defineAsyncComponent, Vue doesn't load the actual module until it's
|
||||
|
|
@ -21,11 +22,15 @@ const RecycleScroller = defineAsyncComponent(() =>
|
|||
const ITEM_SIZE = 28
|
||||
const GRID_ITEMS_PER_ROW = 12
|
||||
const GRID_WIDTH = ITEM_SIZE * GRID_ITEMS_PER_ROW
|
||||
const TOOLTIP_DELAY_MS = 1000
|
||||
|
||||
const { open, anchor, closeIconPicker, selectIcon } = useIconPicker()
|
||||
const { cssSelectorPrefix } = useAppConfig().icon
|
||||
|
||||
const iconFilter = ref("")
|
||||
const tooltipOpen = ref<string | null>(null)
|
||||
// the one tooltip the grid shares, anchored to the hovered cell
|
||||
const tooltip = ref<{ name: string; cell: HTMLElement } | null>(null)
|
||||
let tooltipTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const icons = computed<
|
||||
{
|
||||
|
|
@ -43,6 +48,18 @@ const icons = computed<
|
|||
const floatingElem = useTemplateRef("icon-picker")
|
||||
let cleanupAutoUpdate: (() => void) | null = null
|
||||
|
||||
// the icons' stylesheet is large and only the picker needs it, so it loads
|
||||
// once the page's own load has finished rather than competing with it
|
||||
onMounted(() => {
|
||||
const load = () => void import("virtual:icon-picker.css")
|
||||
|
||||
if (document.readyState === "complete") {
|
||||
load()
|
||||
} else {
|
||||
window.addEventListener("load", load, { once: true })
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("mousedown", handleClickOutside)
|
||||
document.removeEventListener("keydown", handleEscape)
|
||||
|
|
@ -52,7 +69,7 @@ onBeforeUnmount(() => {
|
|||
watch(open, (newV) => {
|
||||
if (newV) {
|
||||
iconFilter.value = ""
|
||||
tooltipOpen.value = null
|
||||
hideTooltip()
|
||||
|
||||
void nextTick(() => {
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
|
|
@ -94,7 +111,35 @@ async function updatePosition() {
|
|||
}
|
||||
|
||||
function handleScroll() {
|
||||
tooltipOpen.value = null
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (tooltipTimer) {
|
||||
clearTimeout(tooltipTimer)
|
||||
tooltipTimer = null
|
||||
}
|
||||
|
||||
tooltip.value = null
|
||||
}
|
||||
|
||||
// one delegated listener for the whole grid, so a cell is a plain button
|
||||
function handleGridHover(e: MouseEvent) {
|
||||
const cell = (e.target as HTMLElement).closest<HTMLElement>("[data-name]")
|
||||
if (!cell) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = cell.dataset.name ?? ""
|
||||
if (tooltip.value?.name === name) {
|
||||
return
|
||||
}
|
||||
|
||||
hideTooltip()
|
||||
|
||||
tooltipTimer = setTimeout(() => {
|
||||
tooltip.value = { name, cell }
|
||||
}, TOOLTIP_DELAY_MS)
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
|
|
@ -179,40 +224,51 @@ function handleEscape(e: KeyboardEvent) {
|
|||
:item-size="ITEM_SIZE"
|
||||
:item-secondary-size="ITEM_SIZE"
|
||||
:grid-items="GRID_ITEMS_PER_ROW"
|
||||
:buffer="400"
|
||||
:buffer="600"
|
||||
key-field="id"
|
||||
skip-hover
|
||||
@scroll="handleScroll"
|
||||
@mouseover="handleGridHover"
|
||||
@mouseleave="hideTooltip"
|
||||
>
|
||||
<ShadcnUiTooltip
|
||||
:delay-duration="1000"
|
||||
:open="tooltipOpen === item.id"
|
||||
@update:open="(v) => (tooltipOpen = v ? item.id : null)"
|
||||
<!--the scroller patches each recycled cell on every scroll, so
|
||||
a cell has to be cheap to patch: plain elements, no components-->
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size: 'icon' }))"
|
||||
:style="{
|
||||
width: `${ITEM_SIZE}px`,
|
||||
height: `${ITEM_SIZE}px`,
|
||||
}"
|
||||
:aria-label="item.name"
|
||||
:data-name="item.name"
|
||||
@click="selectIcon(item.id)"
|
||||
>
|
||||
<ShadcnUiTooltipTrigger as-child>
|
||||
<ShadcnUiButton
|
||||
:style="{
|
||||
width: `${ITEM_SIZE}px`,
|
||||
height: `${ITEM_SIZE}px`,
|
||||
}"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@click="selectIcon(item.id)"
|
||||
>
|
||||
<Icon class="size-[20px]" :name="item.id" />
|
||||
</ShadcnUiButton>
|
||||
</ShadcnUiTooltipTrigger>
|
||||
<ShadcnUiTooltipContent
|
||||
align="center"
|
||||
side="bottom"
|
||||
hide-when-detached
|
||||
position-strategy="absolute"
|
||||
sticky="always"
|
||||
class="px-1.5 py-1"
|
||||
>
|
||||
<span>{{ item.name }}</span>
|
||||
</ShadcnUiTooltipContent>
|
||||
</ShadcnUiTooltip>
|
||||
<span
|
||||
class="iconify size-[20px]"
|
||||
:class="cssSelectorPrefix + item.id"
|
||||
/>
|
||||
</button>
|
||||
</RecycleScroller>
|
||||
<ShadcnUiTooltip :open="tooltip !== null" :delay-duration="0">
|
||||
<!--the trigger registers the anchor; the hovered cell, not the
|
||||
trigger itself, is what the content positions against-->
|
||||
<ShadcnUiTooltipTrigger
|
||||
as="span"
|
||||
class="hidden"
|
||||
:reference="tooltip?.cell"
|
||||
/>
|
||||
<ShadcnUiTooltipContent
|
||||
align="center"
|
||||
side="bottom"
|
||||
hide-when-detached
|
||||
position-strategy="absolute"
|
||||
sticky="always"
|
||||
class="px-1.5 py-1"
|
||||
>
|
||||
<span>{{ tooltip?.name }}</span>
|
||||
</ShadcnUiTooltipContent>
|
||||
</ShadcnUiTooltip>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -648,7 +648,7 @@ const icons: (string | [id: string, name: string])[] = [
|
|||
"simple-icons:julia",
|
||||
"simple-icons:webassembly",
|
||||
"simple-icons:powershell",
|
||||
"mdi:bash",
|
||||
["simple-icons:gnubash", "bash"],
|
||||
|
||||
// frameworks & tooling
|
||||
["simple-icons:vuedotjs", "vue.js"],
|
||||
|
|
|
|||
77
web/modules/icon-picker-css.ts
Normal file
77
web/modules/icon-picker-css.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import type { IconifyJSON } from "@iconify/types"
|
||||
import type { ModuleOptions as IconOptions } from "@nuxt/icon"
|
||||
import { getIconCSS, getIconData } from "@iconify/utils"
|
||||
import { addVitePlugin, defineNuxtModule } from "@nuxt/kit"
|
||||
import { selectableIconList } from "../app/utils/icon"
|
||||
|
||||
// the icon picker styles every selectable icon from one stylesheet built
|
||||
// here out of the installed icon packs, so scrolling it never waits on a
|
||||
// per-icon fetch or style injection. The selectors are the ones @nuxt/icon
|
||||
// generates for <Icon>, so a picked icon and its sidebar rendering share a
|
||||
// rule. Served as the virtual module "virtual:icon-picker.css"
|
||||
const VIRTUAL_ID = "virtual:icon-picker.css"
|
||||
|
||||
export default defineNuxtModule({
|
||||
meta: { name: "icon-picker-css" },
|
||||
setup(_options, nuxt) {
|
||||
const icon: Partial<IconOptions> = nuxt.options.icon || {}
|
||||
const { cssSelectorPrefix = "i-", cssLayer, cssWherePseudo = true } = icon
|
||||
|
||||
addVitePlugin({
|
||||
name: "icon-picker-css",
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_ID) {
|
||||
return VIRTUAL_ID
|
||||
}
|
||||
},
|
||||
load(id) {
|
||||
if (id === VIRTUAL_ID) {
|
||||
return buildStylesheet(cssSelectorPrefix, cssLayer, cssWherePseudo)
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function buildStylesheet(
|
||||
selectorPrefix: string,
|
||||
layer: string | undefined,
|
||||
wherePseudo: boolean,
|
||||
) {
|
||||
const require = createRequire(import.meta.url)
|
||||
const collections = new Map<string, IconifyJSON>()
|
||||
|
||||
const rules = selectableIconList().map((id) => {
|
||||
const [collection, name] = id.split(":") as [string, string]
|
||||
let set = collections.get(collection)
|
||||
|
||||
if (!set) {
|
||||
set = JSON.parse(
|
||||
readFileSync(
|
||||
require.resolve(`@iconify-json/${collection}/icons.json`),
|
||||
"utf8",
|
||||
),
|
||||
) as IconifyJSON
|
||||
collections.set(collection, set)
|
||||
}
|
||||
|
||||
const data = getIconData(set, name)
|
||||
if (!data) {
|
||||
throw new Error(`icon ${id} is not in its installed collection`)
|
||||
}
|
||||
|
||||
// the class name escaping @nuxt/icon applies to the same selector
|
||||
const selector = "." + (selectorPrefix + id).replace(/([^\w-])/g, "\\$1")
|
||||
|
||||
return getIconCSS(data, {
|
||||
iconSelector: wherePseudo ? `:where(${selector})` : selector,
|
||||
format: "compressed",
|
||||
})
|
||||
})
|
||||
|
||||
const css = rules.join("")
|
||||
|
||||
return layer ? `@layer ${layer}{${css}}` : css
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user