diff --git a/examples/next/app/blogs/[blog]/page.val.ts b/examples/next/app/blogs/[blog]/page.val.ts index a32bf7e55..23a2ee8a4 100644 --- a/examples/next/app/blogs/[blog]/page.val.ts +++ b/examples/next/app/blogs/[blog]/page.val.ts @@ -4,7 +4,11 @@ import { linkSchema } from "../../../components/link.val"; const blogSchema = s.object({ title: s.string(), - content: s.richtext(), + content: s.richtext({ + inline: { + a: s.route(), + }, + }), author: s.keyOf(authorsVal), link: linkSchema, }); diff --git a/packages/core/src/module.ts b/packages/core/src/module.ts index cc16910a7..5e6840ad4 100644 --- a/packages/core/src/module.ts +++ b/packages/core/src/module.ts @@ -365,6 +365,20 @@ export function resolvePath< : resolvedSchema : resolvedSchema; } + if ( + "href" in resolvedSource && + "tag" in resolvedSource && + resolvedSource.tag === "a" && + parts.length === 0 + ) { + resolvedSchema = + resolvedSchema instanceof RichTextSchema + ? resolvedSchema["options"]?.inline?.a && + typeof resolvedSchema["options"]?.inline?.a !== "boolean" + ? resolvedSchema["options"].inline.a + : resolvedSchema + : resolvedSchema; + } resolvedSource = resolvedSource[part]; } else { throw Error( @@ -616,6 +630,20 @@ export function safeResolvePath< : resolvedSchema : resolvedSchema; } + if ( + "href" in resolvedSource && + "tag" in resolvedSource && + resolvedSource.tag === "a" && + parts.length === 0 + ) { + resolvedSchema = + resolvedSchema instanceof RichTextSchema + ? resolvedSchema["options"]?.inline?.a && + typeof resolvedSchema["options"]?.inline?.a !== "boolean" + ? resolvedSchema["options"].inline.a + : resolvedSchema + : resolvedSchema; + } resolvedSource = resolvedSource[part]; } else { return { diff --git a/packages/core/src/schema/deserialize.ts b/packages/core/src/schema/deserialize.ts index e574220e9..1df84d462 100644 --- a/packages/core/src/schema/deserialize.ts +++ b/packages/core/src/schema/deserialize.ts @@ -70,12 +70,23 @@ export function deserializeSchema( const deserializedOptions = { ...(serialized.options || {}), inline: - typeof serialized.options?.inline?.img === "object" + typeof serialized.options?.inline?.img === "object" || + typeof serialized.options?.inline?.a === "object" ? { - a: serialized.options.inline.a, - img: deserializeSchema( - serialized.options.inline.img, - ) as ImageSchema>, + a: + typeof serialized.options?.inline?.a === "object" + ? (deserializeSchema(serialized.options.inline.a) as + | RouteSchema + | StringSchema) + : serialized.options?.inline?.a, + img: + typeof serialized.options?.inline?.img === "object" + ? (deserializeSchema( + serialized.options.inline.img, + ) as ImageSchema< + ImageSource | RemoteSource + >) + : serialized.options?.inline?.img, } : (serialized.options?.inline as | undefined diff --git a/packages/core/src/schema/richtext.test.ts b/packages/core/src/schema/richtext.test.ts index 86d0e8e7c..2703897a7 100644 --- a/packages/core/src/schema/richtext.test.ts +++ b/packages/core/src/schema/richtext.test.ts @@ -2,6 +2,8 @@ import { deepEqual } from "../patch"; import { AllRichTextOptions, RichTextSource } from "../source/richtext"; import { SourcePath } from "../val"; import { richtext } from "./richtext"; +import { route } from "./route"; +import { string } from "./string"; import { ValidationErrors } from "./validation/ValidationError"; describe("RichTextSchema", () => { @@ -30,7 +32,7 @@ describe("RichTextSchema", () => { tag: "p", children: [ "Visit ", - { tag: "a", href: "https://val.build", children: ["Val"] }, + { tag: "a", href: "/home", children: ["Val"] }, " for more information.", ], }, @@ -46,7 +48,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }); expectedErrorAtPaths( @@ -69,7 +71,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }) .minLength(1) @@ -94,7 +96,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }).minLength(100); expectedErrorAtPaths( @@ -117,7 +119,7 @@ describe("RichTextSchema", () => { ul: true, }, inline: { - a: true, + a: string(), }, }).maxLength(10); expectedErrorAtPaths( @@ -258,6 +260,265 @@ describe("RichTextSchema", () => { expect(errorAtPath).toHaveLength(1); } }); + + // Anchor href validation tests + test("validate: a: true with valid route href (green test)", () => { + const schema = richtext({ + inline: { + a: true, + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/blogs/blog1", children: ["Blog 1"] }, + " for more information.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Note: a: true now defaults to route validation, which returns router:check-route error + // This is expected and will be processed by Val internally + if (errors !== false) { + expect(Object.keys(errors).length).toBeGreaterThan(0); + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a: route() with explicit route schema", () => { + const schema = richtext({ + inline: { + a: route(), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/blogs/blog1", children: ["Blog 1"] }, + " for more.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Route validation returns router:check-route error for internal processing + if (errors !== false) { + expect(Object.keys(errors).length).toBeGreaterThan(0); + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a: string().maxLength(30) with valid short URL (green test)", () => { + const schema = richtext({ + inline: { + a: string().maxLength(30), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/short", children: ["Link"] }, + " here.", + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + [], + ); + }); + + test("validate: a: string().maxLength(30) with URL exceeding max length (red test)", () => { + const schema = richtext({ + inline: { + a: string().maxLength(30), + }, + }); + const input = [ + { + tag: "p", + children: [ + { + tag: "a", + href: "/this-is-a-very-long-url-that-exceeds-thirty-characters", + children: ["Link"], + }, + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + ['/richtext.val.ts?p=0."children".0."href"'], + ); + }); + + test("validate: a: route().include(/^\\/blog/) with matching route (green test)", () => { + const schema = richtext({ + inline: { + a: route().include(/^\/blog/), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/blog/post1", children: ["Blog"] }, + " here.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Route validation returns router:check-route error for internal processing + if (errors !== false) { + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a: route().exclude(/^\\/admin/) with excluded route (red test)", () => { + const schema = richtext({ + inline: { + a: route().exclude(/^\/admin/), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "/admin/dashboard", children: ["Admin"] }, + " here.", + ], + }, + ]; + const errors = schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ); + // Route validation returns router:check-route error for internal processing + if (errors !== false) { + const firstError = errors[Object.keys(errors)[0] as SourcePath][0]; + expect(firstError.message).toContain("router:check-route"); + } + }); + + test("validate: a tag without href attribute (red test)", () => { + const schema = richtext({ + inline: { + a: true, + }, + }); + const input = [ + { + tag: "p", + children: [ + { tag: "a", children: ["Link"] }, // Missing href + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + ['/richtext.val.ts?p=0."children".0'], + ); + }); + + test("validate: a: string() allows external URLs", () => { + const schema = richtext({ + inline: { + a: string(), + }, + }); + const input = [ + { + tag: "p", + children: [ + "Visit ", + { tag: "a", href: "https://example.com", children: ["External"] }, + " here.", + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + input as any, + ), + [], + ); + }); + + test("validate: a: string().regexp() validates URL patterns", () => { + const schema = richtext({ + inline: { + a: string().regexp(/^https?:\/\//), + }, + }); + const inputValid = [ + { + tag: "p", + children: [ + { tag: "a", href: "https://example.com", children: ["Link"] }, + ], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputValid as any, + ), + [], + ); + + const inputInvalid = [ + { + tag: "p", + children: [{ tag: "a", href: "/relative-url", children: ["Link"] }], + }, + ]; + expectedErrorAtPaths( + schema["executeValidate"]( + "/richtext.val.ts" as SourcePath, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputInvalid as any, + ), + ['/richtext.val.ts?p=0."children".0."href"'], + ); + }); }); function expectedErrorAtPaths(errors: ValidationErrors, paths: string[]) { diff --git a/packages/core/src/schema/richtext.ts b/packages/core/src/schema/richtext.ts index e4b4f4d9c..fd28085a1 100644 --- a/packages/core/src/schema/richtext.ts +++ b/packages/core/src/schema/richtext.ts @@ -15,6 +15,8 @@ import { } from "../source/richtext"; import { SourcePath } from "../val"; import { ImageSchema, SerializedImageSchema } from "./image"; +import { RouteSchema, SerializedRouteSchema } from "./route"; +import { SerializedStringSchema, StringSchema } from "./string"; import { ValidationError, ValidationErrors, @@ -226,8 +228,59 @@ export class RichTextSchema< false, ); } - if (node.tag === "a" && !this.options.inline?.a) { - addError(path, `'a' inline is not valid`, false); + if (node.tag === "a") { + if (!this.options.inline?.a) { + addError(path, `'a' inline is not valid`, false); + } else if (this.options.inline?.a) { + if (!("href" in node)) { + return { + [path]: [ + { + message: `Expected 'href' in 'a'`, + typeError: true, + }, + ], + }; + } + const hrefPath = unsafeCreateSourcePath(path, "href"); + const hrefSchema = + typeof this.options.inline?.a === "boolean" + ? new RouteSchema() + : this.options.inline.a; + + const executeValidate = () => { + if (hrefSchema instanceof RouteSchema) { + return hrefSchema["executeValidate"]( + hrefPath, + node.href as string, + ); + } else if (hrefSchema instanceof StringSchema) { + return hrefSchema["executeValidate"]( + hrefPath, + node.href as string, + ); + } else { + const exhaustiveCheck: never = hrefSchema; + console.error( + "Exhaustive check failed in RichText (anchor href validation)", + exhaustiveCheck, + ); + return false; + } + }; + const hrefValidationErrors = executeValidate(); + if (hrefValidationErrors) { + for (const validationErrorPathS in hrefValidationErrors) { + const validationErrorPath = validationErrorPathS as SourcePath; + if (!current[validationErrorPath]) { + current[validationErrorPath] = []; + } + current[validationErrorPath].push( + ...hrefValidationErrors[validationErrorPath], + ); + } + } + } } if (node.tag === "img") { @@ -562,13 +615,30 @@ export class RichTextSchema< } protected executeSerialize(): SerializedSchema { + const serializeAnchorSchema = (): + | SerializedRouteSchema + | SerializedStringSchema + | boolean + | undefined => { + if (this.options.inline?.a instanceof RouteSchema) { + return this.options.inline?.a[ + "executeSerialize" + ]() as SerializedRouteSchema; + } else if (this.options.inline?.a instanceof StringSchema) { + return this.options.inline?.a[ + "executeSerialize" + ]() as SerializedStringSchema; + } else { + return this.options.inline?.a; + } + }; const serializedOptions: SerializedRichTextOptions & ValidationOptions = { maxLength: this.options.maxLength, minLength: this.options.minLength, style: this.options.style, block: this.options.block, inline: this.options.inline && { - a: this.options.inline.a, + a: serializeAnchorSchema(), img: this.options.inline.img && typeof this.options.inline.img === "object" ? (this.options.inline.img[ diff --git a/packages/core/src/source/richtext.ts b/packages/core/src/source/richtext.ts index 4584101ee..d9dc2a659 100644 --- a/packages/core/src/source/richtext.ts +++ b/packages/core/src/source/richtext.ts @@ -3,6 +3,8 @@ import { ImageSchema, SerializedImageSchema, } from "../schema/image"; +import { RouteSchema, SerializedRouteSchema } from "../schema/route"; +import { StringSchema, SerializedStringSchema } from "../schema/string"; import { FileSource } from "./file"; import { ImageSource } from "./image"; import { RemoteSource } from "./remote"; @@ -26,7 +28,7 @@ export type RichTextOptions = Partial<{ // custom: Record>; }>; inline: Partial<{ - a: boolean; + a: boolean | RouteSchema | StringSchema; img: boolean | ImageSchema>; // custom: Record>; }>; @@ -50,7 +52,7 @@ export type SerializedRichTextOptions = Partial<{ // custom: Record>; }>; inline: Partial<{ - a: boolean; + a: boolean | SerializedRouteSchema | SerializedStringSchema; img: boolean | SerializedImageSchema; // custom: Record>; }>; @@ -155,7 +157,11 @@ export type LinkNode = NonNullable< O["inline"] >["a"] extends true ? LinkTagNode - : never; + : NonNullable["a"] extends + | RouteSchema + | StringSchema + ? LinkTagNode + : never; //#region List type ListItemTagNode = { diff --git a/packages/shared/src/internal/zod/SerializedSchema.ts b/packages/shared/src/internal/zod/SerializedSchema.ts index fe239003a..7bcd8fd25 100644 --- a/packages/shared/src/internal/zod/SerializedSchema.ts +++ b/packages/shared/src/internal/zod/SerializedSchema.ts @@ -116,33 +116,38 @@ export const SerializedImageSchema: z.ZodType = opt: z.boolean(), }); -export const RichTextOptions: z.ZodType = z.object({ - style: z - .object({ - bold: z.boolean().optional(), - italic: z.boolean().optional(), - lineThrough: z.boolean().optional(), - }) - .optional(), - block: z - .object({ - h1: z.boolean().optional(), - h2: z.boolean().optional(), - h3: z.boolean().optional(), - h4: z.boolean().optional(), - h5: z.boolean().optional(), - h6: z.boolean().optional(), - ul: z.boolean().optional(), - ol: z.boolean().optional(), - }) - .optional(), - inline: z - .object({ - a: z.boolean().optional(), - img: z.union([z.boolean(), SerializedImageSchema]).optional(), - }) - .optional(), -}); +export const RichTextOptions: z.ZodType = z.lazy( + () => + z.object({ + style: z + .object({ + bold: z.boolean().optional(), + italic: z.boolean().optional(), + lineThrough: z.boolean().optional(), + }) + .optional(), + block: z + .object({ + h1: z.boolean().optional(), + h2: z.boolean().optional(), + h3: z.boolean().optional(), + h4: z.boolean().optional(), + h5: z.boolean().optional(), + h6: z.boolean().optional(), + ul: z.boolean().optional(), + ol: z.boolean().optional(), + }) + .optional(), + inline: z + .object({ + a: z + .union([z.boolean(), SerializedRouteSchema, SerializedStringSchema]) + .optional(), + img: z.union([z.boolean(), SerializedImageSchema]).optional(), + }) + .optional(), + }), +); export const SerializedRichTextSchema: z.ZodType = z.object({ type: z.literal("richtext"), diff --git a/packages/ui/spa/components/Field.tsx b/packages/ui/spa/components/Field.tsx index 03e61e998..a25a7118c 100644 --- a/packages/ui/spa/components/Field.tsx +++ b/packages/ui/spa/components/Field.tsx @@ -7,9 +7,9 @@ import { useAddPatch, useSchemaAtPath, useShallowSourceAtPath, + useLoadingStatus, } from "./ValFieldProvider"; import { useValidationErrors } from "./ValErrorProvider"; -import { useLoadingStatus } from "./ValProvider"; import { Checkbox } from "./designSystem/checkbox"; import { JSONValue } from "@valbuild/core/patch"; import { ArrayAndRecordTools } from "./ArrayAndRecordTools"; diff --git a/packages/ui/spa/components/Module.tsx b/packages/ui/spa/components/Module.tsx index d0af49c39..6b91ce4d2 100644 --- a/packages/ui/spa/components/Module.tsx +++ b/packages/ui/spa/components/Module.tsx @@ -165,12 +165,11 @@ export function Module({ path }: { path: SourcePath }) { function Home() { return ( -
-
+
+
-

Use the menu on the left to browse and edit your content.

diff --git a/packages/ui/spa/components/NavMenu/NavMenu.tsx b/packages/ui/spa/components/NavMenu/NavMenu.tsx index 32ca57cb8..5c8d44520 100644 --- a/packages/ui/spa/components/NavMenu/NavMenu.tsx +++ b/packages/ui/spa/components/NavMenu/NavMenu.tsx @@ -32,11 +32,7 @@ export type NavMenuProps = { onAddPage?: (moduleFilePath: ModuleFilePath, urlPath: string) => void; }; -export function NavMenu({ - data, - isLoading = false, - onAddPage, -}: NavMenuProps) { +export function NavMenu({ data, isLoading = false, onAddPage }: NavMenuProps) { const { currentSourcePath, navigate } = useNavigation(); const layout = useLayout(); const { theme, setTheme } = useTheme(); diff --git a/packages/ui/spa/components/RichTextEditor.stories.tsx b/packages/ui/spa/components/RichTextEditor.stories.tsx new file mode 100644 index 000000000..97581327f --- /dev/null +++ b/packages/ui/spa/components/RichTextEditor.stories.tsx @@ -0,0 +1,660 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RichTextEditor, useRichTextEditor } from "./RichTextEditor"; +import { + RichTextSource, + AllRichTextOptions, + ModuleFilePath, + SerializedSchema, + ReifiedRender, + initVal, + Internal, + Json, +} from "@valbuild/core"; +import { richTextToRemirror } from "@valbuild/shared/internal"; +import { ValPortalProvider } from "./ValPortalProvider"; +import { Themes, ValThemeProvider } from "./ValThemeProvider"; +import { useMemo, useState } from "react"; +import { ValFieldProvider } from "./ValFieldProvider"; +import { ValSyncEngine } from "../ValSyncEngine"; +import { ValClient } from "@valbuild/shared/internal"; +import { JSONValue } from "@valbuild/core/patch"; + +// Create mock data with routes for testing +function createMockData() { + const { s, c } = initVal(); + + // Create a simple mock router for Storybook + const mockRouter = { + getRouterId: () => "next-app-router", + validate: () => [], + }; + + // Router module for blog pages + const blogPages = c.define( + "/app/blogs/[blog]/page.val.ts", + s + .record( + s.object({ + title: s.string(), + content: s.richtext(), + }), + ) + .router(mockRouter), + { + "/blogs/getting-started": { + title: "Getting Started with Val", + content: [], + }, + "/blogs/advanced-features": { + title: "Advanced Features", + content: [], + }, + "/blogs/best-practices": { + title: "Best Practices", + content: [], + }, + }, + ); + + // Router module for docs pages + const docsPages = c.define( + "/app/docs/[...slug]/page.val.ts", + s + .record( + s.object({ + title: s.string(), + content: s.richtext(), + }), + ) + .router(mockRouter), + { + "/docs/introduction": { + title: "Introduction", + content: [], + }, + "/docs/installation": { + title: "Installation", + content: [], + }, + "/docs/api-reference": { + title: "API Reference", + content: [], + }, + }, + ); + + // Extract schemas and sources from the modules + const modules = [blogPages, docsPages]; + const schemas: Record = {}; + const sources: Record = {}; + const renders: Record = {}; + + for (const module of modules) { + const moduleFilePath = Internal.getValPath(module); + const schema = Internal.getSchema(module); + const source = Internal.getSource(module); + + if (moduleFilePath && schema && source !== undefined) { + const path = moduleFilePath as unknown as ModuleFilePath; + schemas[path] = schema["executeSerialize"](); + sources[path] = source; + renders[path] = schema["executeRender"](path, source); + } + } + + return { + schemas: schemas as Record, + sources: sources as Record, + renders: renders as Record, + }; +} + +// Create a minimal mock ValClient for Storybook +function createMockClient(mockData: { + schemas: Record; + sources: Record; +}): ValClient { + return (async () => { + return { + status: 200, + json: async () => ({ + schemas: mockData.schemas, + sources: mockData.sources, + config: { project: "storybook-test" }, + }), + } as unknown as Awaited>; + }) as ValClient; +} + +// Wrapper component that uses the hook +function RichTextEditorStory({ + content, + options, +}: { + content?: RichTextSource; + options?: { + style?: { + bold?: boolean; + italic?: boolean; + lineThrough?: boolean; + }; + inline?: { + a?: boolean; + img?: boolean; + }; + block?: { + h1?: boolean; + h2?: boolean; + h3?: boolean; + h4?: boolean; + h5?: boolean; + h6?: boolean; + ul?: boolean; + ol?: boolean; + }; + }; +}) { + const initialContent = content ? richTextToRemirror(content) : undefined; + const { manager, state, setState } = useRichTextEditor(initialContent); + const [theme, setTheme] = useState("dark"); + + // Create mock data and sync engine if routes are needed + const mockData = useMemo(() => createMockData(), []); + const client = useMemo(() => createMockClient(mockData), [mockData]); + const syncEngine = useMemo(() => { + const engine = new ValSyncEngine(client, undefined); + engine.setSchemas(mockData.schemas); + engine.setSources( + mockData.sources as Record, + ); + engine.setRenders(mockData.renders); + engine.setInitializedAt(Date.now()); + return engine; + }, [client, mockData]); + + const getDirectFileUploadSettings = useMemo( + () => async () => { + return { + status: "success" as const, + data: { + nonce: null, + baseUrl: "https://mock-upload.example.com", + }, + }; + }, + [], + ); + + const editor = ( +
+ { + setState(params.state); + }} + options={options} + autoFocus={false} + /> +
+ ); + + return ( + + + + {editor} + + + + ); +} + +const meta: Meta = { + title: "Components/RichTextEditor", + component: RichTextEditorStory, + parameters: { + layout: "padded", + }, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Empty editor +export const Empty: Story = { + args: { + content: [], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + ul: true, + ol: true, + }, + }, + }, + name: "Empty", +}; + +// Editor with links (both external and route links) +export const WithLink: Story = { + args: { + content: [ + { + tag: "p", + children: [ + "This is a paragraph with an ", + { + tag: "a", + href: "https://val.build", + children: ["external link"], + }, + " and a ", + { + tag: "a", + href: "/blogs/getting-started", + children: ["route link"], + }, + " in the middle.", + ], + }, + { + tag: "p", + children: [ + "Here are more examples: ", + { + tag: "a", + href: "/blogs/advanced-features", + children: ["Advanced Features"], + }, + ", ", + { + tag: "a", + href: "/docs/introduction", + children: ["Introduction"], + }, + ", and ", + { + tag: "a", + href: "/docs/api-reference", + children: ["API Reference"], + }, + ".", + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Link", +}; + +// Editor with a list +export const WithBulletList: Story = { + args: { + content: [ + { + tag: "p", + children: ["Here's a bullet list:"], + }, + { + tag: "ul", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["First item"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Second item"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Third item"], + }, + ], + }, + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Bullet List", +}; + +// Editor with an ordered list +export const WithOrderedList: Story = { + args: { + content: [ + { + tag: "p", + children: ["Here's an ordered list:"], + }, + { + tag: "ol", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["First step"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Second step"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Third step"], + }, + ], + }, + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Ordered List", +}; + +// Editor with text formatting (bold, italic, strikethrough) +export const WithFormatting: Story = { + args: { + content: [ + { + tag: "p", + children: [ + "This text is ", + { + tag: "span", + styles: ["bold"], + children: ["bold"], + }, + ", this is ", + { + tag: "span", + styles: ["italic"], + children: ["italic"], + }, + ", and this is ", + { + tag: "span", + styles: ["line-through"], + children: ["strikethrough"], + }, + ".", + ], + }, + { + tag: "p", + children: [ + "You can also combine them: ", + { + tag: "span", + styles: ["bold", "italic"], + children: ["bold and italic"], + }, + ".", + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + ul: true, + ol: true, + }, + }, + }, + name: "With Formatting", +}; + +// Comprehensive example with multiple features +export const Comprehensive: Story = { + args: { + content: [ + { + tag: "h1", + children: ["Rich Text Editor Demo"], + }, + { + tag: "p", + children: [ + "This editor supports ", + { + tag: "span", + styles: ["bold"], + children: ["bold"], + }, + ", ", + { + tag: "span", + styles: ["italic"], + children: ["italic"], + }, + ", and ", + { + tag: "span", + styles: ["line-through"], + children: ["strikethrough"], + }, + " text formatting.", + ], + }, + { + tag: "h2", + children: ["Links"], + }, + { + tag: "p", + children: [ + "You can also add ", + { + tag: "a", + href: "https://val.build", + children: ["hyperlinks"], + }, + " to your content.", + ], + }, + { + tag: "h2", + children: ["Lists"], + }, + { + tag: "ul", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["Bullet lists"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Are"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Supported"], + }, + ], + }, + ], + }, + { + tag: "ol", + children: [ + { + tag: "li", + children: [ + { + tag: "p", + children: ["As well as"], + }, + ], + }, + { + tag: "li", + children: [ + { + tag: "p", + children: ["Ordered lists"], + }, + ], + }, + ], + }, + ], + options: { + style: { + bold: true, + italic: true, + lineThrough: true, + }, + inline: { + a: true, + img: true, + }, + block: { + h1: true, + h2: true, + h3: true, + h4: true, + h5: true, + h6: true, + ul: true, + ol: true, + }, + }, + }, + name: "Comprehensive Example", +}; diff --git a/packages/ui/spa/components/RichTextEditor.tsx b/packages/ui/spa/components/RichTextEditor.tsx index c4df79796..1bf38ca44 100644 --- a/packages/ui/spa/components/RichTextEditor.tsx +++ b/packages/ui/spa/components/RichTextEditor.tsx @@ -5,6 +5,9 @@ import { useActive, useChainedCommands, useAttrs, + useCurrentSelection, + useRemirrorContext, + useHelpers, } from "@remirror/react"; import classNames from "classnames"; import { @@ -21,18 +24,26 @@ import { Heading6, ListOrdered, Image, - Unlink, - Check, + X, } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useCallback } from "react"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "./designSystem/dropdown-menu"; +import { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, +} from "./designSystem/popover"; +import { Button } from "./designSystem/button"; +import { Input } from "./designSystem/input"; import { Internal, SerializedRichTextOptions } from "@valbuild/core"; import { readImage } from "../utils/readImage"; +import { RouteSelector } from "./fields/RouteField"; import { BoldExtension } from "@remirror/extension-bold"; import { ItalicExtension } from "@remirror/extension-italic"; import { StrikeExtension } from "@remirror/extension-strike"; @@ -52,6 +63,8 @@ import { EditorState, RemirrorEventListenerProps, } from "@remirror/core"; +import { useValPortal } from "./ValPortalProvider"; +import { useRoutesWithModulePaths } from "./useRoutesOf"; const allExtensions = () => { const extensions = [ @@ -187,14 +200,14 @@ const Toolbar = ({
-
+
{(options?.block?.h1 || options?.block?.h2 || options?.block?.h3 || @@ -203,16 +216,16 @@ const Toolbar = ({ options?.block?.h6 || active.heading()) && ( - - + } + icon={} stroke={3} isOption={options?.style?.bold} isActive={options?.style?.bold || active.bold()} @@ -309,7 +322,7 @@ const Toolbar = ({ )} {(options?.style?.lineThrough || active.strike()) && ( } + icon={} stroke={3} isOption={options?.style?.lineThrough} isActive={options?.style?.lineThrough || active.strike()} @@ -318,7 +331,7 @@ const Toolbar = ({ )} {(options?.style?.italic || active.italic()) && ( } + icon={} stroke={3} isOption={options?.style?.italic} isActive={options?.style?.italic || active.italic()} @@ -327,7 +340,7 @@ const Toolbar = ({ )} {(options?.block?.ul || active.bulletList()) && ( } + icon={} stroke={3} isActive={options?.block?.ul || active.bulletList()} onToggle={() => chain.toggleBulletList().focus().run()} @@ -335,31 +348,17 @@ const Toolbar = ({ )} {(options?.block?.ol || active.orderedList()) && ( } + icon={} stroke={3} isActive={options?.block?.ol || active.orderedList()} onToggle={() => chain.toggleOrderedList().focus().run()} /> )} {(options?.inline?.a || active.link()) && ( - } - stroke={3} - isActive={options?.inline?.a || active.link()} - onToggle={() => - chain - .selectMark("link") - .updateLink({ href: "" }) - .focus() - .run() - } - /> + )} {(options?.inline?.img || active.image()) && ( - + + )} {debug && ( - + )}
-
); }; -export function LinkToolBar() { +function LinkPopover({ options }: { options?: SerializedRichTextOptions }) { + const portalContainer = useValPortal(); const chain = useChainedCommands(); - const [href, setHref] = useState(); const activeLink = useAttrs().link(); + const { empty, to, from } = useCurrentSelection(); + + // Get all routes with their module paths for route selection + const routesWithModulePaths = useRoutesWithModulePaths(); + + const [open, setOpen] = useState(false); + const [linkText, setLinkText] = useState(""); + const [linkHref, setLinkHref] = useState(""); + const [anchorPosition, setAnchorPosition] = useState<{ + x: number; + y: number; + } | null>(null); + + const { getTextBetween } = useHelpers(); + + // Detect if inline.a is a route schema + // When inline.a is true, default to route + // When inline.a is an object with type: "route", it's a route + // When inline.a is an object with type: "string", it's a string + const isRouteLink = + options?.inline?.a === true || + (typeof options?.inline?.a === "object" && + "type" in options.inline.a && + options.inline.a.type === "route"); + + // Extract patterns from schema if it's a route + const routeSchema = + isRouteLink && + options?.inline?.a && + typeof options.inline.a === "object" && + "type" in options.inline.a && + options.inline.a.type === "route" + ? options.inline.a + : undefined; + const includePattern = routeSchema?.options?.include + ? new RegExp( + routeSchema.options.include.source, + routeSchema.options.include.flags, + ) + : undefined; + const excludePattern = routeSchema?.options?.exclude + ? new RegExp( + routeSchema.options.exclude.source, + routeSchema.options.exclude.flags, + ) + : undefined; + + const { view } = useRemirrorContext(); + // Handle opening the popover + const handleOpenPopover = useCallback(() => { + // If cursor is on a link, always select the entire link first + const hasExistingLink = activeLink !== undefined; + if (hasExistingLink) { + // Select the entire link mark, whether or not there's already a selection + chain.selectMark("link").run(); + + // Get the href from activeLink + + // Small delay to let the selection update, then get the selected text + setTimeout(() => { + const selection = view.state.selection; + const selectedText = getTextBetween(selection.from, selection.to); + setLinkText(selectedText); + setLinkHref(activeLink.href as string); + + // Get selection position for anchor + const coords = view.coordsAtPos(selection.from); + setAnchorPosition({ x: coords.left, y: coords.top }); + + setOpen(true); + }, 10); + } else { + // Get current cursor position for anchor + const selection = view.state.selection; + const coords = view.coordsAtPos(selection.from); + setAnchorPosition({ x: coords.left, y: coords.top }); + + setOpen(true); + } + }, [activeLink, chain, view, getTextBetween]); useEffect(() => { - const href = - typeof activeLink === "object" && - "href" in activeLink && - typeof activeLink.href === "string" - ? activeLink.href - : undefined; - setHref(href); - }, [activeLink]); - - const isEnabled = - //active.link() || // doesn't seem to work for the first char (of a link) of a line, so we could remove this since selectedHref does the trick? - activeLink !== undefined; - if (!isEnabled) { - return null; - } + const handleDoubleClick = (event: MouseEvent) => { + const target = event.target as HTMLElement; + const linkElement = target.tagName === "A" ? target : target.closest("a"); + + if (linkElement) { + event.preventDefault(); + event.stopPropagation(); + const pos = view.posAtDOM(linkElement, 0); + chain.selectText(pos).selectMark("link").run(); + setLinkText(linkElement.textContent || ""); + setLinkHref(linkElement.getAttribute("href") || ""); + + // Get position for anchor + const coords = view.coordsAtPos(pos); + setAnchorPosition({ x: coords.left, y: coords.top }); + + setTimeout(() => setOpen(true), 10); + } + }; + + const editorElement = view.dom; + editorElement.addEventListener("dblclick", handleDoubleClick); + return () => { + editorElement.removeEventListener("dblclick", handleDoubleClick); + }; + }, [view, chain]); + + const handleSubmit = () => { + if (!linkText.trim() || !linkHref.trim()) { + return; + } + + if (empty) { + chain + .insertText(linkText) + .selectText({ from, to: to + linkText.length }) + .updateLink({ href: linkHref }) + .focus() + .run(); + } else { + chain + .replaceText({ content: linkText }) + .updateLink({ href: linkHref }) + .focus() + .run(); + } + + setOpen(false); + setLinkText(""); + setLinkHref(""); + }; + + const handleRemove = () => { + chain.removeLink().focus().run(); + setOpen(false); + setLinkText(""); + setLinkHref(""); + }; + + const handleCancel = () => { + setOpen(false); + setLinkText(""); + setLinkHref(""); + }; + + const isSubmitDisabled = !linkText.trim() || !linkHref.trim(); + return ( -
- { - setHref(ev.target.value); - }} - defaultValue={href} - placeholder="https://" - > - - + + {anchorPosition && ( + +
+ + )} + - - -
+
+ +
+
+ + setLinkText(e.target.value)} + /> +
+
+ + {isRouteLink ? ( + setLinkHref(route)} + includePattern={includePattern} + excludePattern={excludePattern} + placeholder="Select route..." + portalContainer={portalContainer} + zIndex={ + 9000 // not ideal, but it works: we need to use isolate but for now we're using a fixed z-index (this portal is already 8999) we want to keep below 9000 which NextJSs / Vercel menus + } + /> + ) : ( + setLinkHref(e.target.value)} + /> + )} +
+
+
+ {!empty && ( + + )} +
+
+ +
+
+
+
+ + ); } @@ -490,7 +694,9 @@ function ToolbarButton({ stroke: 2 | 3; }) { return ( - + ); } diff --git a/packages/ui/spa/components/Search.tsx b/packages/ui/spa/components/Search.tsx index 38f03362e..f90a3004d 100644 --- a/packages/ui/spa/components/Search.tsx +++ b/packages/ui/spa/components/Search.tsx @@ -21,7 +21,7 @@ import { SearchResultsList, type SearchResult } from "./SearchResultsList"; import { getNavPathFromAll } from "./getNavPath"; import { useSearchWorker } from "../search/useSearchWorker"; -export function Search() { +export function Search({ container }: { container?: HTMLElement }) { const sources = useAllSources(); const schemasRes = useSchemas(); const schemas = schemasRes.status === "success" ? schemasRes.data : undefined; @@ -65,7 +65,7 @@ export function Search() { setOpen(true)}> - + { return initializedAt.data; }; +export type LoadingStatus = "loading" | "not-asked" | "error" | "success"; +export function useLoadingStatus(): LoadingStatus { + const { syncEngine } = useContext(ValFieldContext); + const pendingOpsCount = useSyncExternalStore( + syncEngine.subscribe("pending-ops-count"), + () => syncEngine.getPendingOpsSnapshot(), + () => syncEngine.getPendingOpsSnapshot(), + ); + if (pendingOpsCount > 0) { + return "loading"; + } + return "success"; +} + const textEncoder = new TextEncoder(); const SavePatchFileResponse = z.object({ patchId: z.string().refine((v): v is PatchId => v.length > 0), diff --git a/packages/ui/spa/components/ValPath.tsx b/packages/ui/spa/components/ValPath.tsx index 90d7af34d..ed829b7eb 100644 --- a/packages/ui/spa/components/ValPath.tsx +++ b/packages/ui/spa/components/ValPath.tsx @@ -154,8 +154,7 @@ export function ValPath({ , React.ComponentPropsWithoutRef & { @@ -28,4 +30,4 @@ const PopoverContent = React.forwardRef< )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; -export { Popover, PopoverTrigger, PopoverContent }; +export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }; diff --git a/packages/ui/spa/components/fields/RouteField.tsx b/packages/ui/spa/components/fields/RouteField.tsx index 24d58b786..2ad42f1c3 100644 --- a/packages/ui/spa/components/fields/RouteField.tsx +++ b/packages/ui/spa/components/fields/RouteField.tsx @@ -32,9 +32,107 @@ import { Link, Check, ChevronsUpDown } from "lucide-react"; import { ValidationErrors } from "../../components/ValidationError"; import { useRoutesWithModulePaths } from "../useRoutesOf"; +export interface RouteSelectorProps { + routes: Array<{ route: string; moduleFilePath: string }>; + value: string | null; + onChange: (route: string) => void; + includePattern?: RegExp; + excludePattern?: RegExp; + placeholder?: string; + className?: string; + portalContainer?: HTMLElement | null; + isLoading?: boolean; + zIndex?: number; +} + +export function RouteSelector({ + routes, + value, + onChange, + includePattern, + excludePattern, + placeholder = "Select route...", + className, + portalContainer, + isLoading = false, + zIndex, +}: RouteSelectorProps) { + const [open, setOpen] = React.useState(false); + + // Filter routes based on include/exclude patterns + const filteredRoutes = routes.filter((routeInfo) => { + // If include pattern exists, route must match it + if (includePattern && !includePattern.test(routeInfo.route)) { + return false; + } + // If exclude pattern exists, route must NOT match it + if (excludePattern && excludePattern.test(routeInfo.route)) { + return false; + } + return true; + }); + + return ( + + + + + + + + + {isLoading ? ( +
Loading...
+ ) : filteredRoutes.length === 0 ? ( + No routes found. + ) : ( + + {filteredRoutes.map((routeInfo) => ( + { + onChange(currentValue); + setOpen(false); + }} + > + + {routeInfo.route} + + ))} + + )} +
+
+
+
+ ); +} + export function RouteField({ path }: { path: SourcePath }) { const type = "route"; - const [open, setOpen] = React.useState(false); const { navigate } = useNavigation(); const schemaAtPath = useSchemaAtPath(path); const sourceAtPath = useShallowSourceAtPath(path, type); @@ -94,18 +192,6 @@ export function RouteField({ path }: { path: SourcePath }) { ? new RegExp(schema.options.exclude.source, schema.options.exclude.flags) : undefined; - const filteredRoutes = routesWithModulePaths.filter((routeInfo) => { - // If include pattern exists, route must match it - if (includePattern && !includePattern.test(routeInfo.route)) { - return false; - } - // If exclude pattern exists, route must NOT match it - if (excludePattern && excludePattern.test(routeInfo.route)) { - return false; - } - return true; - }); - // Find the module path for the currently selected route const selectedRouteInfo = source ? routesWithModulePaths.find((r) => r.route === source) @@ -117,66 +203,26 @@ export function RouteField({ path }: { path: SourcePath }) {
- - - - - - - - - {isLoading ? ( -
Loading...
- ) : filteredRoutes.length === 0 ? ( - No routes found. - ) : ( - - {filteredRoutes.map((routeInfo) => ( - { - addPatch( - [ - { - op: "replace", - path: patchPath, - value: currentValue, - }, - ], - type, - ); - setOpen(false); - }} - > - - {routeInfo.route} - - ))} - - )} -
-
-
-
+ { + addPatch( + [ + { + op: "replace", + path: patchPath, + value: route, + }, + ], + type, + ); + }} + includePattern={includePattern} + excludePattern={excludePattern} + portalContainer={portalContainer} + isLoading={isLoading} + /> {source && selectedRouteInfo && (