diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..54fcf3cb --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "name": "Node.js with Bun", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye", + "customizations": { + "vscode": { + "extensions": ["streetsidesoftware.code-spell-checker"] + } + }, + "portsAttributes": { + "3000": { + "label": "Application", + "onAutoForward": "notify" + } + }, + "postCreateCommand": "/bin/bash -c 'curl -fsSL https://bun.sh/install | bash && export PATH=\"$HOME/.bun/bin:$PATH\" && bun install'" +} \ No newline at end of file diff --git a/packages/ui/src/v2/components/keyboardHandlers.ts b/packages/ui/src/v2/components/keyboardHandlers.ts new file mode 100644 index 00000000..3bd54deb --- /dev/null +++ b/packages/ui/src/v2/components/keyboardHandlers.ts @@ -0,0 +1,35 @@ +export function handleMentionKeyDown( + e: KeyboardEvent, + mentionOpen: boolean, + mention: any, + { closeMention, selectActiveMention, submit, onCancel }: any + ) { + if (e.isComposing || e.keyCode === 229) return + + if (mentionOpen) { + if (e.key === "Escape") { + e.preventDefault() + closeMention() + return true + } + if (e.key === "Tab" && mention.flat().length > 0) { + e.preventDefault() + selectActiveMention() + return true + } + } + + if (e.key === "Escape") { + e.preventDefault() + onCancel() + return true + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + submit() + return true + } + + return false + } \ No newline at end of file diff --git a/packages/ui/src/v2/components/line-comment-v2.tsx b/packages/ui/src/v2/components/line-comment-v2.tsx index 056b3ddb..7c309580 100644 --- a/packages/ui/src/v2/components/line-comment-v2.tsx +++ b/packages/ui/src/v2/components/line-comment-v2.tsx @@ -1,8 +1,9 @@ -import { For, Show, createSignal, onMount, splitProps, type ComponentProps, type JSX } from "solid-js" +import { For, Show, onMount, splitProps, type ComponentProps, type JSX } from "solid-js" import { FileIcon } from "../../components/file-icon" import { useI18n } from "../../context/i18n" -import { useFilteredList } from "../../hooks" import { ButtonV2 } from "./button-v2" +import { useMentionHandler } from "../components/useMentionHandler" +import { handleMentionKeyDown } from "../components/keyboardHandlers" import "./line-comment-v2.css" /** Horizontal “more” glyph for the display-card overflow control (Figma outline-dots). */ @@ -89,7 +90,6 @@ function pathDirectory(path: string) { export function LineCommentEditorV2(props: LineCommentEditorV2Props) { const i18n = useI18n() let textareaRef: HTMLTextAreaElement | undefined - const [mentionOpen, setMentionOpen] = createSignal(false) const [local, rest] = splitProps(props, [ "heading", @@ -108,81 +108,14 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { "classList", ]) + const { mentionOpen, closeMention, selectMention, mention, syncMention, selectActiveMention } = useMentionHandler( + () => textareaRef, + local, + ) + const heading = () => local.heading ?? i18n.t("ui.lineComment.submit") const canSubmit = () => local.value.trim().length > 0 - const closeMention = () => { - setMentionOpen(false) - mention.clear() - } - - const currentMention = () => { - const textarea = textareaRef - if (!textarea) return - if (!local.mention) return - if (textarea.selectionStart !== textarea.selectionEnd) return - - const end = textarea.selectionStart - const match = textarea.value.slice(0, end).match(/@(\S*)$/) - if (!match) return - - return { - query: match[1] ?? "", - start: end - match[0].length, - end, - } - } - - function selectMention(item: { path: string } | undefined) { - if (!item) return - - const textarea = textareaRef - const query = currentMention() - if (!textarea || !query) return - - const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}` - const cursor = query.start + item.path.length + 2 - - local.onInput(value) - closeMention() - - requestAnimationFrame(() => { - textarea.focus() - textarea.setSelectionRange(cursor, cursor) - }) - } - - const mention = useFilteredList<{ path: string }>({ - items: async (query) => { - if (!local.mention) return [] - if (!query.trim()) return [] - const paths = await local.mention.items(query) - return paths.map((path) => ({ path })) - }, - key: (item) => item.path, - filterKeys: ["path"], - skipFilter: () => true, - onSelect: selectMention, - }) - - const syncMention = () => { - const item = currentMention() - if (!item) { - closeMention() - return - } - - setMentionOpen(true) - mention.onInput(item.query) - } - - const selectActiveMention = () => { - const items = mention.flat() - if (items.length === 0) return - const active = mention.active() - selectMention(items.find((item) => item.path === active) ?? items[0]) - } - const submit = () => { const v = local.value.trim() if (!v) return @@ -194,6 +127,8 @@ export function LineCommentEditorV2(props: LineCommentEditorV2Props) { requestAnimationFrame(() => textareaRef?.focus()) }) + + return (
syncMention()} onKeyDown={(e) => { e.stopPropagation() - if (e.isComposing || e.keyCode === 229) return - - if (mentionOpen()) { - if (e.key === "Escape") { - e.preventDefault() - closeMention() - return - } - - if (e.key === "Tab") { - if (mention.flat().length === 0) return - e.preventDefault() - selectActiveMention() - return - } - - const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter" - const ctrlNav = e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p") - if ((nav || ctrlNav) && mention.flat().length > 0) { - mention.onKeyDown(e) - e.preventDefault() - return - } - } - - if (e.key === "Escape") { - e.preventDefault() - e.currentTarget.blur() - local.onCancel() - return - } - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault() - submit() - } + handleMentionKeyDown(e, mentionOpen(), mention, { + closeMention, + selectActiveMention, + submit, + onCancel: local.onCancel, + }) }} /> 0}> diff --git a/packages/ui/src/v2/components/mention-utils.test.ts b/packages/ui/src/v2/components/mention-utils.test.ts new file mode 100644 index 00000000..19f64e33 --- /dev/null +++ b/packages/ui/src/v2/components/mention-utils.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { extractMentionQuery, insertMention } from "./mention-utils" + +describe("extractMentionQuery", () => { + test("extracts mention query when @ is present", () => { + const result = extractMentionQuery("Hello @use", 10) + expect(result).toEqual({ query: "use", start: 6, end: 10 }) + }) + + test("extracts empty query for just @", () => { + const result = extractMentionQuery("Hello @", 7) + expect(result).toEqual({ query: "", start: 6, end: 7 }) + }) + + test("returns undefined when no @ is present", () => { + const result = extractMentionQuery("Hello world", 11) + expect(result).toBeUndefined() + }) + + test("returns undefined when @ is not at end of word", () => { + const result = extractMentionQuery("Hello @user more", 12) + expect(result).toBeUndefined() + }) +}) + +describe("insertMention", () => { + test("inserts mention and returns new value and cursor position", () => { + const result = insertMention("Hello ", "user.tsx", 6, 6) + expect(result.value).toBe("Hello @user.tsx ") + expect(result.cursorPosition).toBe(16) + }) + + test("replaces partial mention with full mention", () => { + const result = insertMention("Hello @us", "user.tsx", 6, 9) + expect(result.value).toBe("Hello @user.tsx ") + expect(result.cursorPosition).toBe(16) + }) + + test("handles mention in middle of text", () => { + const result = insertMention("Hello @user and more", "component.tsx", 6, 11) + expect(result.value).toBe("Hello @component.tsx and more") + expect(result.cursorPosition).toBe(21) + }) +}) diff --git a/packages/ui/src/v2/components/mention-utils.ts b/packages/ui/src/v2/components/mention-utils.ts new file mode 100644 index 00000000..f4314686 --- /dev/null +++ b/packages/ui/src/v2/components/mention-utils.ts @@ -0,0 +1,33 @@ +/** + * Extracts mention query from textarea value at cursor position + */ +export function extractMentionQuery( + value: string, + cursorPosition: number, +): { query: string; start: number; end: number } | undefined { + const beforeCursor = value.slice(0, cursorPosition) + const match = beforeCursor.match(/@(\S*)$/) + + if (!match) return undefined + + return { + query: match[1] ?? "", + start: cursorPosition - match[0].length, + end: cursorPosition, + } +} + +/** + * Inserts selected mention into text + */ +export function insertMention( + value: string, + mentionPath: string, + start: number, + end: number, +): { value: string; cursorPosition: number } { + const newValue = `${value.slice(0, start)}@${mentionPath} ${value.slice(end)}` + const cursorPosition = start + mentionPath.length + 2 + + return { value: newValue, cursorPosition } +} diff --git a/packages/ui/src/v2/components/useMentionHandler.ts b/packages/ui/src/v2/components/useMentionHandler.ts new file mode 100644 index 00000000..463202c0 --- /dev/null +++ b/packages/ui/src/v2/components/useMentionHandler.ts @@ -0,0 +1,90 @@ +import { createSignal } from "solid-js" +import { useFilteredList } from "../../hooks" + +export function useMentionHandler( + getTextareaRef: () => HTMLTextAreaElement | undefined, + local: any, +) { + const [mentionOpen, setMentionOpen] = createSignal(false) + + const closeMention = () => { + setMentionOpen(false) + mention.clear() + } + + const currentMention = () => { + const textarea = getTextareaRef() + if (!textarea) return + if (!local.mention) return + if (textarea.selectionStart !== textarea.selectionEnd) return + + const end = textarea.selectionStart + const match = textarea.value.slice(0, end).match(/@(\S*)$/) + if (!match) return + + return { + query: match[1] ?? "", + start: end - match[0].length, + end, + } + } + + const selectMention = (item: { path: string } | undefined) => { + if (!item) return + + const textarea = getTextareaRef() + const query = currentMention() + if (!textarea || !query) return + + const value = `${textarea.value.slice(0, query.start)}@${item.path} ${textarea.value.slice(query.end)}` + const cursor = query.start + item.path.length + 2 + + local.onInput(value) + closeMention() + + requestAnimationFrame(() => { + textarea.focus() + textarea.setSelectionRange(cursor, cursor) + }) + } + + const mention = useFilteredList<{ path: string }>({ + items: async (query: string) => { + if (!local.mention) return [] + if (!query.trim()) return [] + const paths = await local.mention.items(query) + return paths.map((path: string) => ({ path })) + }, + key: (item: {path: string}) => item.path, + filterKeys: ["path"], + skipFilter: () => true, + onSelect: selectMention, + }) + + const syncMention = () => { + const item = currentMention() + if (!item) { + closeMention() + return + } + + setMentionOpen(true) + mention.onInput(item.query) + } + + const selectActiveMention = () => { + const items = mention.flat() + if (items.length === 0) return + const active = mention.active() + selectMention(items.find((item) => item.path === active) ?? items[0]) + } + + return { + mentionOpen, + closeMention, + selectMention, + mention, + syncMention, + selectActiveMention, + } +} \ No newline at end of file