diff --git a/web/src/common/text/measurement.ts b/web/src/common/text/measurement.ts new file mode 100644 index 000000000000..c39b885d3ba2 --- /dev/null +++ b/web/src/common/text/measurement.ts @@ -0,0 +1,47 @@ +/** + * @file Text measurement utilities. + */ + +/** + * Measures text dimensions using an offscreen canvas. + */ +export class TextMeasurer implements Disposable { + #ctx: OffscreenCanvasRenderingContext2D | null; + + public get canvas(): OffscreenCanvas | null { + return this.#ctx?.canvas ?? null; + } + + public get font(): string | null { + return this.#ctx?.font ?? null; + } + + public set font(value: string) { + if (!this.#ctx) return; + + this.#ctx!.font = value; + } + + constructor(canvas: OffscreenCanvas = new OffscreenCanvas(300, 150)) { + this.#ctx = canvas.getContext("2d"); + + if (!this.#ctx) { + console.error("failed to get canvas context"); + } + + const styles = window.getComputedStyle(document.body); + + this.font = `${styles.fontSize} monospace`; + } + + public measure(text: string): TextMetrics { + if (!this.#ctx) { + throw new TypeError("CanvasRenderingContext2D is null"); + } + return this.#ctx.measureText(text); + } + + public [Symbol.dispose](): void { + this.#ctx = null; + } +} diff --git a/web/src/elements/AppIcon.css b/web/src/elements/AppIcon.css index b66cf2942ab6..7968b4cfb743 100644 --- a/web/src/elements/AppIcon.css +++ b/web/src/elements/AppIcon.css @@ -38,6 +38,7 @@ font-size: var(--icon-font-size, var(--icon-height)); color: var(--ak-global--Color--100); padding: var(--icon-border); + height: var(--icon-height); max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border)); line-height: 1; filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color)); diff --git a/web/src/user/LibraryApplication/index.ts b/web/src/user/LibraryApplication/index.ts index e34b338cd2e8..9d03b358a8e2 100644 --- a/web/src/user/LibraryApplication/index.ts +++ b/web/src/user/LibraryApplication/index.ts @@ -28,6 +28,7 @@ export interface AKLibraryAppProps extends HTMLAttributes { application?: Application; editURL?: string | URL | null; background?: string | null; + titleWidth?: number; appIndex: number; groupIndex: number; targetRef?: RefOrCallback | null; @@ -37,6 +38,7 @@ export const AKLibraryApp: LitFC = ({ application, editURL, background, + titleWidth, appIndex, groupIndex, className = "", @@ -83,7 +85,10 @@ export const AKLibraryApp: LitFC = ({ part="card-wrapper" data-application-name=${ifPresent(dataID)} aria-describedby=${descriptionID} - style=${styleMap({ background: background || null })} + style=${styleMap({ + "background": background || null, + "--ak-card-title-width": titleWidth ? `${titleWidth}px` : null, + })} ${spread(props)} >
diff --git a/web/src/user/LibraryPage/ApplicationList.css b/web/src/user/LibraryPage/ApplicationList.css index c9cefe647833..e8150509cb6e 100644 --- a/web/src/user/LibraryPage/ApplicationList.css +++ b/web/src/user/LibraryPage/ApplicationList.css @@ -5,11 +5,16 @@ --app-card-min-width: 6.5rem; --app-group-header-min-height: calc(var(--app-card-min-width) / 4); --app-icon-offset: 1rem; - --app-card-row-coefficient: 3.5; + --app-card-row-coefficient: 2.5; + --app-card-font-size-minimum: var(--pf-global--FontSize--md); + --app-card-font-size-maximum: var(--pf-global--FontSize--md); + --app-card-line-clamp: 3; @media (min-width: 390px) { + --app-card-row-coefficient: 3.5; --app-card-aspect-ratio: 1; --app-group-template-columns: repeat(auto-fill, var(--app-card-min-width)); + --app-card-font-size-minimum: 0.6rem; } @media (min-width: 409px) { @@ -19,6 +24,8 @@ @media (min-width: 768px) { --app-icon-offset: 0.5rem; --app-card-min-width: 10rem; + --app-card-font-size-minimum: 0.9rem; + --app-card-line-clamp: 3; } } @@ -76,6 +83,15 @@ /* #region Title */ [part="card-title"] { + --font-size-adjustment: calc( + (var(--app-card-min-width) / var(--ak-card-title-width, var(--app-card-min-width))) + ); + --pf-c-card__title--FontSize: clamp( + var(--app-card-font-size-minimum), + calc(1rem * var(--font-size-adjustment, 1)), + var(--app-card-font-size-maximum) + ); + padding: 0 !important; z-index: 1; text-stroke-width: 0.15em; @@ -93,16 +109,16 @@ .clamp-wrapper { --clamp-padding: calc(0.1em * var(--app-card-row-coefficient)); - display: box; display: -webkit-box; - line-clamp: 2; - -webkit-line-clamp: 2; + line-clamp: var(--app-card-line-clamp); + -webkit-line-clamp: var(--app-card-line-clamp); box-orient: vertical; -webkit-box-orient: vertical; overflow: hidden; text-align: center; text-wrap: balance; + word-break: break-word; line-height: 1.2; padding-block: var(--clamp-padding); max-height: calc((var(--pf-global--LineHeight--md) * 2rem) - (var(--clamp-padding) / 2)); diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ApplicationList.ts index 7c11b157f431..6401a6c033be 100644 --- a/web/src/user/LibraryPage/ApplicationList.ts +++ b/web/src/user/LibraryPage/ApplicationList.ts @@ -30,6 +30,7 @@ const LayoutColumnCount = { } as const satisfies Record; export interface AKLibraryApplicationListProps extends HTMLAttributes { + measurements?: WeakMap; groupedApps: AppGroupEntry[]; layout: LayoutType; background?: string | null; @@ -46,6 +47,7 @@ export const AKLibraryApplicationList: LitFC = ({ background, selectedApp, targetRef, + measurements, ...props }) => { const columnCount = LayoutColumnCount[layout] ?? 1; @@ -84,6 +86,7 @@ export const AKLibraryApplicationList: LitFC = ({ apps, (application) => application.pk, (application, appIndex) => { + const titleWidth = measurements?.get(application); const selected = selectedApp === application; const editURL = canEdit @@ -96,6 +99,7 @@ export const AKLibraryApplicationList: LitFC = ({ groupIndex, background, editURL, + titleWidth, "targetRef": selected ? targetRef : null, "aria-selected": selected, }); diff --git a/web/src/user/LibraryPage/ak-library-impl.ts b/web/src/user/LibraryPage/ak-library-impl.ts index 6897d4b75dbf..e3ca10fc2127 100644 --- a/web/src/user/LibraryPage/ak-library-impl.ts +++ b/web/src/user/LibraryPage/ak-library-impl.ts @@ -8,6 +8,7 @@ import { AKLibraryApplicationList } from "./ApplicationList.js"; import { appHasLaunchUrl } from "./LibraryPageImpl.utils.js"; import type { PageUIConfig } from "./types.js"; +import { TextMeasurer } from "#common/text/measurement"; import { groupBy } from "#common/utils"; import { AKSkipToContent } from "#elements/a11y/ak-skip-to-content"; @@ -82,6 +83,9 @@ export class LibraryPage extends AKElement { @property({ type: Boolean }) public admin = false; + #textMeasurer = new TextMeasurer(); + + #titleMeasurements = new WeakMap(); #applications: Application[] = []; /** @@ -98,6 +102,12 @@ export class LibraryPage extends AKElement { this.#applications = value; this.fuse.setCollection(this.searchEnabled ? this.#applications : []); + + for (const app of this.#applications) { + const metrics = this.#textMeasurer.measure(app.name); + const titleWidth = Math.ceil(metrics.width); + this.#titleMeasurements.set(app, titleWidth); + } } /** @@ -296,6 +306,7 @@ export class LibraryPage extends AKElement { background, selectedApp, groupedApps, + measurements: this.#titleMeasurements, targetRef: this.targetRef, }); }