From 37ea6fc245901874f456f36ed7c7ed1f0ba03f09 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Apr 2026 10:15:39 +0000 Subject: [PATCH] feat(creator): add markdown link command triggered by / key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing / in the creator textarea opens a command palette that lists the user's articles with search and keyboard navigation (↑↓/Enter/Esc). Selecting an article inserts a [name](url) markdown link at the cursor. Includes a mock document generator following the project convention for development/testing without a live backend. https://claude.ai/code/session_01KbSCecVVqLtkmVkfNLaK8i --- .../markdown-link-command.container.tsx | 212 ++++++++++++++++++ src/features/creator/creator.view.tsx | 38 ++++ .../creator/mocks/generate-mock-documents.ts | 51 +++++ 3 files changed, 301 insertions(+) create mode 100644 src/features/creator/containers/markdown-link-command.container.tsx create mode 100644 src/features/creator/mocks/generate-mock-documents.ts diff --git a/src/features/creator/containers/markdown-link-command.container.tsx b/src/features/creator/containers/markdown-link-command.container.tsx new file mode 100644 index 00000000..94ac028a --- /dev/null +++ b/src/features/creator/containers/markdown-link-command.container.tsx @@ -0,0 +1,212 @@ +import React from "react"; +import { BiSearch, BiX } from "react-icons/bi"; +import { Modal2 } from "design-system/modal2"; +import { Loader } from "design-system/loader"; +import { Button } from "design-system/button"; +import { useDocsStore } from "store/docs/docs.store"; +import { getYourDocumentsAct } from "acts/get-your-documents.act"; +import type { API4MarkdownDto } from "api-4markdown-contracts"; +import c from "classnames"; +import { meta } from "../../../../meta"; + +type DocItem = API4MarkdownDto<`getYourDocuments`>[number]; + +interface MarkdownLinkCommandContainerProps { + onInsert(link: string): void; + onClose(): void; +} + +const MarkdownLinkCommandContainer = ({ + onInsert, + onClose, +}: MarkdownLinkCommandContainerProps) => { + const docsStore = useDocsStore(); + const [searchQuery, setSearchQuery] = React.useState(``); + const [activeIndex, setActiveIndex] = React.useState(0); + const searchInputRef = React.useRef(null); + const listRef = React.useRef(null); + + React.useEffect(() => { + if (docsStore.is === `idle`) { + getYourDocumentsAct(); + } + }, [docsStore.is]); + + React.useEffect(() => { + searchInputRef.current?.focus(); + }, []); + + const filteredDocs = React.useMemo((): DocItem[] => { + if (docsStore.is !== `ok`) return []; + + const query = searchQuery.trim().toLowerCase(); + + if (!query) return docsStore.docs; + + return docsStore.docs.filter( + (doc) => + doc.name.toLowerCase().includes(query) || + doc.id.toLowerCase().includes(query), + ); + }, [docsStore, searchQuery]); + + React.useEffect(() => { + setActiveIndex(0); + }, [searchQuery]); + + const handleSearchChange = (e: React.ChangeEvent): void => { + setSearchQuery(e.target.value); + }; + + const selectDoc = React.useCallback( + (doc: DocItem): void => { + const url = `${meta.siteUrl}${meta.routes.documents.preview}?id=${doc.id}`; + onInsert(`[${doc.name}](${url})`); + }, + [onInsert], + ); + + const handleKeyDown = (e: React.KeyboardEvent): void => { + if (e.key === `ArrowDown`) { + e.preventDefault(); + setActiveIndex((prev) => Math.min(prev + 1, filteredDocs.length - 1)); + } else if (e.key === `ArrowUp`) { + e.preventDefault(); + setActiveIndex((prev) => Math.max(prev - 1, 0)); + } else if (e.key === `Enter`) { + e.preventDefault(); + const doc = filteredDocs[activeIndex]; + if (doc) selectDoc(doc); + } + }; + + React.useEffect(() => { + if (listRef.current) { + const activeItem = listRef.current.children[activeIndex] as + | HTMLElement + | undefined; + activeItem?.scrollIntoView({ block: `nearest` }); + } + }, [activeIndex]); + + const pending = + docsStore.is === `idle` || docsStore.is === `busy`; + + return ( + + +
+ +
+
+
+ +
+ +
+
+ + + {pending && } + + {docsStore.is === `fail` && ( +

+ Failed to load articles. Please try again. +

+ )} + + {docsStore.is === `ok` && filteredDocs.length === 0 && ( +

+ {searchQuery.trim().length > 0 + ? `No articles found matching "${searchQuery}"` + : `No articles available`} +

+ )} + + {docsStore.is === `ok` && filteredDocs.length > 0 && ( +
    + {filteredDocs.map((doc, index) => ( +
  • selectDoc(doc)} + onMouseEnter={() => setActiveIndex(index)} + > + + {doc.visibility} + + {doc.name} +
  • + ))} +
+ )} +
+ + + + ↑↓ navigate + + + insert link + + + Esc close + + +
+ ); +}; + +export { MarkdownLinkCommandContainer }; diff --git a/src/features/creator/creator.view.tsx b/src/features/creator/creator.view.tsx index bcbe0574..7711caf6 100644 --- a/src/features/creator/creator.view.tsx +++ b/src/features/creator/creator.view.tsx @@ -50,6 +50,12 @@ const CreatorErrorModalContainer = React.lazy( () => import(`./containers/creator-error-modal.container`), ); +const MarkdownLinkCommandContainer = React.lazy(() => + import(`./containers/markdown-link-command.container`).then((m) => ({ + default: m.MarkdownLinkCommandContainer, + })), +); + const RewriteAssistantModule = React.lazy(() => import(`../../modules/rewrite-assistant/rewrite-assistant.module`).then( (m) => ({ @@ -99,6 +105,7 @@ const CreatorView = () => { to: number; }>(); const rewriteAssistant = useSimpleFeature(); + const markdownLinkCommand = useFeature<{ slashPosition: number }>(); const [view, setView] = React.useState<`creator` | `preview`>(`preview`); const docStore = useDocStore(); const previousDocStore = usePrevious(docStore); @@ -119,9 +126,32 @@ const CreatorView = () => { e.preventDefault(); } + if (e.key === `/`) { + markdownLinkCommand.on({ slashPosition: e.currentTarget.selectionStart }); + } + autoScroller.scroll(e.currentTarget); }; + const insertMarkdownLink = (link: string): void => { + if (markdownLinkCommand.is === `off`) return; + + const textarea = creatorRef.current; + + if (!textarea) return; + + const { slashPosition } = markdownLinkCommand.data; + const currentValue = textarea.value; + const newCode = + currentValue.slice(0, slashPosition) + + link + + currentValue.slice(slashPosition + 1); + + textarea.value = newCode; + changeAction(newCode); + markdownLinkCommand.off(); + }; + const changeCode: ChangeEventHandler = (e) => { const timeout = timeoutRef.current; @@ -246,6 +276,14 @@ const CreatorView = () => { )} + {markdownLinkCommand.is === `on` && ( + + + + )} {cheatsheetModal.isOn && ( diff --git a/src/features/creator/mocks/generate-mock-documents.ts b/src/features/creator/mocks/generate-mock-documents.ts new file mode 100644 index 00000000..2422dfdd --- /dev/null +++ b/src/features/creator/mocks/generate-mock-documents.ts @@ -0,0 +1,51 @@ +import type { API4MarkdownDto, Atoms } from "api-4markdown-contracts"; + +type MockDocument = Extract< + API4MarkdownDto<"getYourDocuments">[number], + { visibility: "private" } +>; + +const documentNames = [ + `Introduction to TypeScript`, + `React Performance Tips`, + `Understanding Zustand`, + `CSS Grid Complete Guide`, + `Node.js Best Practices`, + `GraphQL vs REST API`, + `Docker for Developers`, + `Git Workflow Strategies`, + `Testing React Components`, + `Web Accessibility Guide`, +]; + +const generateMockDocument = (index: number): MockDocument => { + const now = Date.now(); + const daysAgo = index * 3; + const nameBase = documentNames[index % documentNames.length]; + const nameSuffix = + index >= documentNames.length + ? ` ${Math.floor(index / documentNames.length) + 1}` + : ``; + + return { + id: `mock-doc-${index + 1}` as Atoms[`DocumentId`], + name: `${nameBase}${nameSuffix}`, + code: `# ${nameBase}\n\nSample content for document ${index + 1}.`, + visibility: `private`, + commentsCount: 0, + mdate: new Date(now - daysAgo * 86400000).toISOString() as Atoms[`UTCDate`], + cdate: new Date( + now - (daysAgo + 5) * 86400000, + ).toISOString() as Atoms[`UTCDate`], + path: `/mock-doc-${index + 1}` as Atoms[`Path`], + score: { average: 0, count: 0, values: [] }, + }; +}; + +const generateMockDocuments = ( + count: number, + startIndex: number = 0, +): API4MarkdownDto<`getYourDocuments`> => + Array.from({ length: count }, (_, i) => generateMockDocument(startIndex + i)); + +export { generateMockDocuments };