feat: bilingual support (FA/EN), IRANSans typography, RTL layout, and interactive product detail pages
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
import type { JsonObject } from "../shared/shared";
|
||||
import { shared } from "../shared/shared";
|
||||
|
||||
/** Where a pointer is, as `<Cursors>` sends it in `presence.cursor` and `cursor:move`: fractions of its region or, with `anchor`, of that element's box, the region it is in and the sender's clock in ms. Over a surface seen through a camera, `space: "page"` and that surface's own units instead. */
|
||||
export type CursorPoint = JsonObject & {
|
||||
x: number;
|
||||
y: number;
|
||||
t?: number;
|
||||
region?: string;
|
||||
space?: "page";
|
||||
/** `#id` of the nearest ancestor with a token id, or `""` for the region itself, then `tag:n` steps down to the element. */
|
||||
anchor?: string[];
|
||||
dx?: number;
|
||||
dy?: number;
|
||||
};
|
||||
|
||||
/** One of the six emoji, sent from where the pointer was. */
|
||||
export type Emote = CursorPoint & { emoji: string };
|
||||
|
||||
/** The point two hands meet at, and whose hand the sender's meets. */
|
||||
export type HighFive = CursorPoint & { with: string };
|
||||
|
||||
/** The sender's wave starting or ending, in the region it started in. */
|
||||
export interface Wave {
|
||||
region?: string;
|
||||
on: boolean;
|
||||
}
|
||||
|
||||
/** Text someone selected inside a `<Cursors>` area, as `presence.selection`: the path of each end from that area (child indices, then the offset in the last node) and a hash of the text, so it is drawn only over the same text. */
|
||||
export type TextSelection = JsonObject & {
|
||||
from: number[];
|
||||
to: number[];
|
||||
hash: string;
|
||||
region?: string;
|
||||
};
|
||||
|
||||
/** What an `id` must look like to travel: generated ids pass, names and titles do not. */
|
||||
export const TOKEN = /^[\w-]{1,64}$/;
|
||||
export const MAX_STEPS = 24;
|
||||
/** One `tag:n` step of an anchor, and nothing a selector could trip on. */
|
||||
const STEP = /^[A-Za-z][\w-]{0,63}:\d{1,6}$/;
|
||||
|
||||
export function fraction(value: number): number {
|
||||
return Math.round(value * 1e4) / 1e4;
|
||||
}
|
||||
|
||||
/** The region key a point or a wave carries, when it has one. */
|
||||
export function regionOf(region: string | undefined): JsonObject {
|
||||
return region === undefined ? {} : { region };
|
||||
}
|
||||
|
||||
/** The units a `<Cursors>` reads and writes: its region's fractions, or with a `space`, the surface's own units tagged `"page"`. */
|
||||
export type Units = "page" | undefined;
|
||||
|
||||
/** `value` when it is a cursor point in `region` and `units` as `<Cursors>` sends one; an anchor this page cannot walk is left out. */
|
||||
export function pointOf(
|
||||
value: unknown,
|
||||
region: string | undefined,
|
||||
units: Units = undefined,
|
||||
): CursorPoint | undefined {
|
||||
const point = value as CursorPoint | null;
|
||||
if (
|
||||
!point ||
|
||||
typeof point !== "object" ||
|
||||
!Number.isFinite(point.x) ||
|
||||
!Number.isFinite(point.y) ||
|
||||
point.region !== region ||
|
||||
point.space !== units
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
isAnchor(point.anchor) &&
|
||||
typeof point.dx === "number" &&
|
||||
typeof point.dy === "number"
|
||||
) {
|
||||
return point;
|
||||
}
|
||||
const { anchor, dx, dy, ...fractions } = point;
|
||||
return fractions as CursorPoint;
|
||||
}
|
||||
|
||||
/** `payload` when it is a `cursor:emote` in `region` and `units`. */
|
||||
export function emoteOf(
|
||||
payload: unknown,
|
||||
region: string | undefined,
|
||||
units: Units,
|
||||
): Emote | undefined {
|
||||
const point = pointOf(payload, region, units);
|
||||
return typeof point?.emoji === "string" ? (point as Emote) : undefined;
|
||||
}
|
||||
|
||||
/** `payload` when it is a `cursor:high-five` in `region` and `units`. */
|
||||
export function highFiveOf(
|
||||
payload: unknown,
|
||||
region: string | undefined,
|
||||
units: Units,
|
||||
): HighFive | undefined {
|
||||
const point = pointOf(payload, region, units);
|
||||
return typeof point?.with === "string" ? (point as HighFive) : undefined;
|
||||
}
|
||||
|
||||
/** `payload` when it is a `cursor:wave` in `region`. */
|
||||
export function waveOf(
|
||||
payload: unknown,
|
||||
region: string | undefined,
|
||||
): Wave | undefined {
|
||||
const wave = payload as Wave | null;
|
||||
return wave &&
|
||||
typeof wave === "object" &&
|
||||
wave.region === region &&
|
||||
typeof wave.on === "boolean"
|
||||
? wave
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** The presence's cursor while it is in `region` and `units`. */
|
||||
export function cursorOf(
|
||||
presence: JsonObject,
|
||||
region: string | undefined,
|
||||
units: Units = undefined,
|
||||
): CursorPoint | undefined {
|
||||
return pointOf(presence.cursor, region, units);
|
||||
}
|
||||
|
||||
/** The presence's chat message while they are typing one. */
|
||||
export function messageOf(presence: JsonObject): string | undefined {
|
||||
const message = presence.message;
|
||||
return typeof message === "string" && message !== "" ? message : undefined;
|
||||
}
|
||||
|
||||
/** The presence's selection when it has the shape `<Cursors>` writes and is in `region`. */
|
||||
export function selectionOf(
|
||||
presence: JsonObject,
|
||||
region: string | undefined,
|
||||
): TextSelection | undefined {
|
||||
const selection = presence.selection as TextSelection | null | undefined;
|
||||
return typeof selection === "object" &&
|
||||
selection !== null &&
|
||||
isPath(selection.from) &&
|
||||
isPath(selection.to) &&
|
||||
typeof selection.hash === "string" &&
|
||||
selection.region === region
|
||||
? selection
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/** My three presence keys as I have them now; every publish sends all three and leaves the rest of my presence alone. */
|
||||
const mine: {
|
||||
cursor?: CursorPoint;
|
||||
message?: string;
|
||||
selection?: TextSelection;
|
||||
} = {};
|
||||
|
||||
/** Notes where my pointer is without publishing it; the next publish carries it. */
|
||||
export function moved(cursor: CursorPoint): void {
|
||||
mine.cursor = cursor;
|
||||
}
|
||||
|
||||
/** Sets the given keys of mine, `undefined` taking one away, and publishes all of them. */
|
||||
export function publish(update: Partial<typeof mine> = {}): void {
|
||||
Object.assign(mine, update);
|
||||
shared.me.setPresence(({ cursor, message, selection, ...rest }) => ({
|
||||
...rest,
|
||||
...(mine.cursor && { cursor: mine.cursor }),
|
||||
...(mine.message && { message: mine.message }),
|
||||
...(mine.selection && { selection: mine.selection }),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Whether an anchor another page sent has the shape and size this page will walk. */
|
||||
function isAnchor(value: unknown): value is string[] {
|
||||
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_STEPS) {
|
||||
return false;
|
||||
}
|
||||
const [root, ...steps] = value as unknown[];
|
||||
return (
|
||||
(root === "" ||
|
||||
(typeof root === "string" &&
|
||||
root.startsWith("#") &&
|
||||
TOKEN.test(root.slice(1)))) &&
|
||||
steps.every((step) => typeof step === "string" && STEP.test(step))
|
||||
);
|
||||
}
|
||||
|
||||
function isPath(value: unknown): value is number[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
value.every((index) => Number.isInteger(index) && index >= 0)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user