diff --git a/apps/mobile-pwa/src/getPlugin/getPluginOptions.ts b/apps/mobile-pwa/src/getPlugin/getPluginOptions.ts index f76205d..4221350 100644 --- a/apps/mobile-pwa/src/getPlugin/getPluginOptions.ts +++ b/apps/mobile-pwa/src/getPlugin/getPluginOptions.ts @@ -24,7 +24,6 @@ import { DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, - DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuTrigger, @@ -167,7 +166,6 @@ export const getPluginOptions = (slug: string) => ({ DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuShortcut, Dialog, DialogContent, DialogDescription, diff --git a/apps/server/prisma/migrations/20241026061350_add_shortcuts/migration.sql b/apps/server/prisma/migrations/20241026061350_add_shortcuts/migration.sql new file mode 100644 index 0000000..f977b00 --- /dev/null +++ b/apps/server/prisma/migrations/20241026061350_add_shortcuts/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Shortcut" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMPTZ NOT NULL, + "slug" TEXT NOT NULL, + "pluginSlug" TEXT NOT NULL, + "elementId" TEXT NOT NULL, + "trigger" TEXT[], + "enabled" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Shortcut_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Shortcut_slug_pluginSlug_key" ON "Shortcut"("slug", "pluginSlug"); diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 84a6ca9..3682c0a 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -347,6 +347,27 @@ model Template { routineStepId Int? } +model Shortcut { + /// The id of the shortcut + id Int @id @default(autoincrement()) + /// The date and time of creation of the shortcut + createdAt DateTime @default(now()) @db.Timestamptz() + /// The date and time the shortcut was last updated + updatedAt DateTime @updatedAt @db.Timestamptz() + /// The slug of the shortcut + slug String + /// The plugin slug of the shortcut + pluginSlug String + /// Focused element type + elementId String + /// How the shortcut is triggered (see type ShortcutTrigger in apps/web for more info) + trigger String[] + /// Whether the shortcut is enabled. This can be used to pause a shortcut. + enabled Boolean @default(true) + + @@unique([slug, pluginSlug], name: "slug_pluginSlug_unique") +} + enum RepetitionPattern { MONDAY TUESDAY diff --git a/apps/server/prisma/seed.ts b/apps/server/prisma/seed.ts index 38f7e33..5dd7c1a 100644 --- a/apps/server/prisma/seed.ts +++ b/apps/server/prisma/seed.ts @@ -88,7 +88,7 @@ await prisma.day.create({ data: [ { status: "TODO", - title: "Make a new friend", + title: "Meet a friend", durationInMinutes: 120, itemId: item.id, }, @@ -210,4 +210,13 @@ await prisma.routine.create({ }, }); +await prisma.shortcut.create({ + data: { + pluginSlug: "flow", + slug: "create-task", + elementId: "Day", + trigger: ["c"], + }, +}); + console.log("✅ Seeding complete!"); diff --git a/apps/server/src/graphql/PrismaFilters.ts b/apps/server/src/graphql/PrismaFilters.ts index 099f5f2..27d4e47 100644 --- a/apps/server/src/graphql/PrismaFilters.ts +++ b/apps/server/src/graphql/PrismaFilters.ts @@ -18,6 +18,12 @@ export const IntFilter = builder.prismaFilter("Int", { ops: ["equals", "lt", "lte", "gt", "gte", "in", "not", "notIn"], }); +export const StringFilter = builder.prismaFilter("String", { + name: "PrismaStringFilter", + description: "Filter input of String", + ops: ["equals", "in", "not", "notIn", "contains", "startsWith", "endsWith"], +}); + export const JsonFilter = builder.prismaFilter("JSON", { name: "PrismaJsonFilter", description: "Filter input of JSON", diff --git a/apps/server/src/graphql/Shortcut.ts b/apps/server/src/graphql/Shortcut.ts new file mode 100644 index 0000000..25063d9 --- /dev/null +++ b/apps/server/src/graphql/Shortcut.ts @@ -0,0 +1,113 @@ +import { prisma } from "../utils/prisma"; +import { builder, u } from "./builder"; +import { JsonFilter, StringFilter } from "./PrismaFilters"; + +export const ShortcutType = builder.prismaNode("Shortcut", { + id: { field: "id" }, + fields: (t) => ({ + createdAt: t.expose("createdAt", { type: "DateTime" }), + updatedAt: t.expose("updatedAt", { type: "DateTime" }), + slug: t.exposeString("slug"), + pluginSlug: t.exposeString("pluginSlug"), + enabled: t.exposeBoolean("enabled"), + elementId: t.exposeString("elementId"), + trigger: t.exposeStringList("trigger"), + }), +}); + +export const ShortcutWhereInputType = builder.prismaWhere("Shortcut", { + fields: { + pluginSlug: StringFilter, + slug: StringFilter, + trigger: JsonFilter, + AND: true, + OR: true, + NOT: true, + }, +}); + +export const ShortcutOrderByType = builder.prismaOrderBy("Shortcut", { + fields: { + slug: true, + createdAt: true, + updatedAt: true, + }, +}); + +builder.queryField("shortcuts", (t) => + t.prismaConnection({ + type: "Shortcut", + cursor: "id", + description: "Get keyboard shortcuts.", + args: { + where: t.arg({ type: ShortcutWhereInputType, required: false }), + orderBy: t.arg({ type: ShortcutOrderByType, required: false }), + }, + resolve: async (query, _, args) => { + return prisma.shortcut.findMany({ + ...query, + where: args.where ?? undefined, + orderBy: args.orderBy ?? undefined, + }); + }, + }), +); + +builder.mutationField("upsertShortcut", (t) => + t.prismaFieldWithInput({ + type: "Shortcut", + description: "Create a new shortcut.", + input: { + slug: t.input.string({ required: true }), + pluginSlug: t.input.string({ required: true }), + elementId: t.input.string({ required: true }), + trigger: t.input.stringList({ required: true }), + enabled: t.input.boolean({ required: false }), + }, + resolve: async (_, __, args) => { + return prisma.shortcut.upsert({ + where: { + slug_pluginSlug_unique: { slug: args.input.slug, pluginSlug: args.input.pluginSlug }, + }, + create: { + slug: args.input.slug, + pluginSlug: args.input.pluginSlug, + elementId: args.input.elementId, + trigger: args.input.trigger, + }, + update: { + trigger: args.input.trigger, + enabled: u(args.input.enabled), + }, + }); + }, + }), +); + +builder.mutationField("disableShortcut", (t) => + t.prismaField({ + type: "Shortcut", + description: "Disable a shortcut.", + args: { id: t.arg.globalID({ required: true }) }, + resolve: async (_, __, args) => { + return prisma.shortcut.update({ + where: { id: parseInt(args.id.id) }, + data: { enabled: false }, + }); + }, + }), +); + +builder.mutationField("enableShortcut", (t) => + t.prismaField({ + type: "Shortcut", + description: "Enable a shortcut.", + args: { id: t.arg.globalID({ required: true }) }, + resolve: async (_, __, args) => { + return prisma.shortcut.update({ + where: { id: parseInt(args.id.id) }, + data: { enabled: true }, + }); + }, + }), +); diff --git a/apps/server/src/graphql/Task.ts b/apps/server/src/graphql/Task.ts index 7aa6d2c..92dfb79 100644 --- a/apps/server/src/graphql/Task.ts +++ b/apps/server/src/graphql/Task.ts @@ -250,6 +250,89 @@ builder.mutationField("createTask", (t) => }), ); +builder.mutationField("cloneTask", (t) => + t.prismaFieldWithInput({ + type: [DayType], + description: `Clone an existing task into a new day. + +It will clone the task, taskPluginDatas linked to it and link to the same item if there was one to the original task. The original task will be left intact. + +If the new task is for today, it will have the same status as the original task. +If the new task is in the past, it will have the status \`DONE\` and the completedAt set to the end of the day. +If the new task is in the future, it will have the status \`TODO\` and the completedAt set to null. + +Returns all the days that were updated (as a list in chronological order).`, + input: { + id: t.input.globalID({ required: true, description: "The Relay ID of the task to clone." }), + date: t.input.field({ + type: "Date", + required: true, + description: "The date to clone the task into.", + }), + newTasksOrder: t.input.globalIDList({ + required: true, + description: "The new order of the tasks in the day the task is cloned into.", + }), + }, + resolve: async (query, _, args) => { + const newDate = args.input.date; + const task = await prisma.task.findUnique({ + where: { id: parseInt(args.input.id.id) }, + include: { day: { select: { date: true, tasksOrder: true } }, pluginDatas: true }, + }); + if (!task) { + throw new GraphQLError(`Task with ID ${args.input.id.id} not found.`, { + extensions: { + code: "TASK_NOT_FOUND", + userFriendlyMessage: + "The task was not found. Please try refreshing the page and try again.", + }, + }); + } + await prisma.$transaction(async (tx) => { + const newTasksOrder = args.input.newTasksOrder.map((id) => parseInt(id.id)); + const isInPast = dayjs().startOf("day").isAfter(newDate); + const isToday = dayjs().isSame(newDate, "day"); + const isInFuture = dayjs().endOf("day").isBefore(newDate); + + await tx.task.create({ + data: { + title: task.title, + status: isToday ? task.status : isInPast ? "DONE" : isInFuture ? "TODO" : "TODO", + completedAt: isInPast ? dayjs(newDate).endOf("day").toDate() : null, + durationInMinutes: task.durationInMinutes, + day: { connectOrCreate: { where: { date: newDate }, create: { date: newDate } } }, + ...(task.itemId ? { item: { connect: { id: task.itemId } } } : {}), + pluginDatas: { + createMany: { + data: task.pluginDatas.map((pluginData) => ({ + pluginSlug: pluginData.pluginSlug, + originalId: pluginData.originalId, + min: pluginData.min ?? {}, + full: pluginData.full ?? {}, + })), + }, + }, + }, + }); + + // update just one day as they are the same + await tx.day.update({ + ...query, + where: { date: newDate }, + data: { tasksOrder: { set: newTasksOrder } }, + }); + }); + + return prisma.day.findMany({ + ...query, + where: { date: { in: [newDate, task.date] } }, + orderBy: { date: "asc" }, + }); + }, + }), +); + const TaskPluginDataInput = builder.inputType("TaskPluginDataInput", { fields: (t) => ({ pluginSlug: t.string({ required: true }), diff --git a/apps/server/src/graphql/builder.ts b/apps/server/src/graphql/builder.ts index c32190a..1649d1d 100644 --- a/apps/server/src/graphql/builder.ts +++ b/apps/server/src/graphql/builder.ts @@ -100,7 +100,6 @@ export const builder = new SchemaBuilder<{ smartSubscriptions: { subscribe: async ($name, context, callback) => { const name = $name as PubSubKeys; - console.log("subscribing", name); if (!context.subscriptions[name]) context.subscriptions[name] = pubsub.subscribe(name); for await (const data of context.subscriptions[name]) { callback(undefined, data); @@ -108,7 +107,6 @@ export const builder = new SchemaBuilder<{ }, unsubscribe: ($name, context) => { const name = $name as PubSubKeys; - console.log("unsubscribing", name); context.subscriptions[name]?.return?.(); delete context.subscriptions[name]; }, diff --git a/apps/server/src/graphql/index.ts b/apps/server/src/graphql/index.ts index e97a7e1..27a7dfd 100644 --- a/apps/server/src/graphql/index.ts +++ b/apps/server/src/graphql/index.ts @@ -20,6 +20,7 @@ import "./TaskPluginData"; import "./TaskTag"; import "./Util.ts"; import "./Plugin"; +import "./Shortcut.ts"; import { env } from "../env"; export const schema = builder.toSchema(); diff --git a/apps/server/src/utils/getPluginOptions.ts b/apps/server/src/utils/getPluginOptions.ts index b63d1d8..5bb8b22 100644 --- a/apps/server/src/utils/getPluginOptions.ts +++ b/apps/server/src/utils/getPluginOptions.ts @@ -86,6 +86,7 @@ export const getPluginOptions = (pluginSlug: string) => { routine: prisma.routine, routineStep: prisma.routineStep, template: prisma.template, + shortcut: prisma.shortcut, }, /** * A key-value store plugins can use to store data, user preferences, settings, configurations, etc. diff --git a/apps/web/package.json b/apps/web/package.json index 5b070d7..a05e92e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@swc/plugin-relay": "^1.5.36", + "@types/mousetrap": "^1.6.15", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.9", "@vitejs/plugin-react": "4.1.0", diff --git a/apps/web/src/components/Day.tsx b/apps/web/src/components/Day.tsx index 324cb3f..6203c87 100644 --- a/apps/web/src/components/Day.tsx +++ b/apps/web/src/components/Day.tsx @@ -14,6 +14,12 @@ import { getPlugins } from "@flowdev/web/getPlugin"; import { OnCreateTask, OnCreateTaskProps, PluginStepInfo } from "./OnCreateTask"; import { DragContext, useDragContext } from "../useDragContext"; import { getStartOfToday } from "./CalendarList"; +import { useIsPressing, useShortcutsOnHover } from "./Shortcuts"; +import { + DayShortcuts_day$data, + DayShortcuts_day$key, +} from "@flowdev/web/relay/__gen__/DayShortcuts_day.graphql"; +import { DayCloneTaskMutation } from "../relay/__gen__/DayCloneTaskMutation.graphql"; type DayProps = { day: Day_day$key; @@ -25,14 +31,15 @@ export const Day = (props: DayProps) => { graphql` fragment Day_day on Day { date + ...DayShortcuts_day ...DayContent_day ...DayAddTaskActionsBar_day } `, props.day, ); - const dayRef = useRef(null); + const ref = useDayShortcuts({ day }); useEffect(() => { const today = getStartOfToday().format("YYYY-MM-DD"); @@ -42,7 +49,7 @@ export const Day = (props: DayProps) => { }, [dayRef]); return ( -
+
-// -// -// -// No type found. -// -// {Object.entries(types).map(([value, taskType]) => ( -// -// -// -// -// {taskType.label} -// -// ))} -// -// -// -// -// -// -// -// -// -// No type found. -// -// {Object.entries(types).map(([value, taskType]) => ( -// -// -// -// -// -// {taskType.label} -// -// ))} -// -// -// +//
+// //
//
// ); -// } +// }; + +// const Component = (props: any) => { +// console.log(props.node.attrs); +// return ( +// +// +// +// +// New tasks will be added here. +// +// +// New tasks that match the filters you set in the Post in Slack routine step will be +// added here (defaults to today's tasks). +//
+//
+// New tasks will be rendered using this template: +//
+//             {props.node.attrs.innerHTML}
+//           
+//
+//
+//
+// ); +// }; + +// const SlackMark = () => ( +// +// +// +// +// +// +// +// +// +// +// ); + +const TestViewContent = () => { + return ; +}; + +const types: Record = { + CODE: { label: "Code", iconClassName: "text-blue-600" }, + SPEC: { label: "Spec", iconClassName: "text-gray-600" }, + REVIEW: { label: "Review", iconClassName: "text-yellow-600" }, + QA: { label: "QA", iconClassName: "text-purple-600" }, + LEARNING: { label: "Learning", iconClassName: "text-green-700" }, +}; + +function ComboboxDemo() { + // useSimpleShortcuts([ + // { + // trigger: "1", + // handler: (e) => { + // console.log("1", e); + // }, + // }, + // ]); + + return ( +
+
+ {/* console.log("triggered 2")}>2 */} + + + + + + + No type found. + + {Object.entries(types).map(([value, taskType], i) => ( + + + + + {taskType.label} + + ))} + + + + + + + + + + No type found. + + {Object.entries(types).map(([value, taskType]) => ( + + + + + + {taskType.label} + + ))} + + + +
+
+ ); +} diff --git a/bun.lockb b/bun.lockb index adcfd27..9e4ca78 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/plugin/web/index.ts b/packages/plugin/web/index.ts index 4c8f86c..d99415f 100644 --- a/packages/plugin/web/index.ts +++ b/packages/plugin/web/index.ts @@ -14,6 +14,7 @@ import type { import type { PluginRenderLists as RenderLists } from "@flowdev/web/src/components/RenderLists"; import type { PluginRenderList as RenderList } from "@flowdev/web/src/components/RenderList"; import type { RenderRoutineStepSettings_routineStep$data } from "@flowdev/web/src/relay/__gen__/RenderRoutineStepSettings_routineStep.graphql"; +import type { PluginShortcuts } from "@flowdev/web/src/components/Shortcuts"; import type { Extensions } from "@tiptap/core"; import { ComponentType } from "react"; @@ -117,6 +118,10 @@ export type WebPlugin = (options: WebPluginOptions) => { * TipTap extensions to use in the NoteEditor component. */ noteEditorTipTapExtensions?: Extensions; + /** + * Shortcuts to add to the web app. + */ + shortcuts?: PluginShortcuts; }; export const definePlugin = (plugin: WebPlugin) => ({ plugin }); diff --git a/packages/relay/schema.graphql b/packages/relay/schema.graphql index 55711dc..9904b35 100644 --- a/packages/relay/schema.graphql +++ b/packages/relay/schema.graphql @@ -220,6 +220,19 @@ type Mutation { Change password for the Flow instance and get a new session token to make authenticated requests. """ changePassword(input: MutationChangePasswordInput!): String! + + """ + Clone an existing task into a new day. + + It will clone the task, taskPluginDatas linked to it and link to the same item if there was one to the original task. The original task will be left intact. + + If the new task is for today, it will have the same status as the original task. + If the new task is in the past, it will have the status `DONE` and the completedAt set to the end of the day. + If the new task is in the future, it will have the status `TODO` and the completedAt set to null. + + Returns all the days that were updated (as a list in chronological order). + """ + cloneTask(input: MutationCloneTaskInput!): [Day!]! completeRoutine(input: MutationCompleteRoutineInput!): Boolean! """Create an item in the calendar.""" @@ -267,11 +280,17 @@ type Mutation { id: ID! ): Template! + """Disable a shortcut.""" + disableShortcut(id: ID!): Shortcut! + """ Dismiss an item from the inbox. This effectively sets `inboxPoints = null` for the item. """ dismissItemFromInbox(input: MutationDismissItemFromInboxInput!): Item! + """Enable a shortcut.""" + enableShortcut(id: ID!): Shortcut! + """ Install a plugin. If a plugin with the same slug exists, it will throw an error, unless `override` is set to true. """ @@ -345,6 +364,9 @@ type Mutation { """Update a template.""" updateTemplate(input: MutationUpdateTemplateInput!): Template! + """Create a new shortcut.""" + upsertShortcut(input: MutationUpsertShortcutInput!): Shortcut! + """ Creates a store item. If a store item with the same key exists, only its value will be updated (`isSecret` and `isServerOnly` will not be updated). @@ -365,6 +387,17 @@ input MutationChangePasswordInput { oldPassword: String! } +input MutationCloneTaskInput { + """The date to clone the task into.""" + date: Date! + + """The Relay ID of the task to clone.""" + id: ID! + + """The new order of the tasks in the day the task is cloned into.""" + newTasksOrder: [ID!]! +} + input MutationCompleteRoutineInput { """The date the routine was completed.""" date: Date! @@ -688,6 +721,14 @@ input MutationUpdateTemplateInput { routineStepId: ID } +input MutationUpsertShortcutInput { + elementId: String! + enabled: Boolean + pluginSlug: String! + slug: String! + trigger: [String!]! +} + input MutationUpsertStoreItemInput { isSecret: Boolean isServerOnly: Boolean @@ -885,6 +926,17 @@ input PrismaJsonFilter { path: JSON } +"""Filter input of String""" +input PrismaStringFilter { + contains: String + endsWith: String + equals: String + in: [String!] + not: PrismaStringFilter + notIn: [String!] + startsWith: String +} + type Query { canRefreshCalendarItems: Boolean! @@ -967,6 +1019,9 @@ type Query { """Get all routines.""" routines: [Routine!]! + """Get keyboard shortcuts.""" + shortcuts(after: ID, before: ID, first: Int, last: Int, orderBy: ShortcutOrderBy, where: ShortcutFilter): QueryShortcutsConnection! + """ Get store items. @@ -1048,6 +1103,16 @@ input QueryRenderTemplateInput { template: String! } +type QueryShortcutsConnection { + edges: [QueryShortcutsConnectionEdge!]! + pageInfo: PageInfo! +} + +type QueryShortcutsConnectionEdge { + cursor: ID! + node: Shortcut! +} + input QueryStoreItemsInput { keys: [String!] pluginSlug: String @@ -1148,6 +1213,47 @@ input RoutineStepInput { stepSlug: String! } +type Shortcut implements Node { + """The date and time of creation of the shortcut""" + createdAt: DateTime! + + """Focused element type""" + elementId: String! + + """Whether the shortcut is enabled. This can be used to pause a shortcut.""" + enabled: Boolean! + id: ID! + + """The plugin slug of the shortcut""" + pluginSlug: String! + + """The slug of the shortcut""" + slug: String! + + """ + How the shortcut is triggered (see type ShortcutTrigger in apps/web for more info) + """ + trigger: [String!]! + + """The date and time the shortcut was last updated""" + updatedAt: DateTime! +} + +input ShortcutFilter { + AND: [ShortcutFilter!] + NOT: ShortcutFilter + OR: [ShortcutFilter!] + pluginSlug: PrismaStringFilter + slug: PrismaStringFilter + trigger: PrismaJsonFilter +} + +input ShortcutOrderBy { + createdAt: OrderBy + slug: OrderBy + updatedAt: OrderBy +} + """ A key-value store containing settings the user has modified for both Flow and plugins (by convention, the key is prefixed with the plugin's slug), or configs and secrents that plugins have stored such as API keys. """ diff --git a/packages/ui/Combobox.tsx b/packages/ui/Combobox.tsx index 73cd88f..8adc358 100644 --- a/packages/ui/Combobox.tsx +++ b/packages/ui/Combobox.tsx @@ -7,7 +7,6 @@ import { CommandItem, CommandList, CommandSeparator, - CommandShortcut, } from "./Command"; import { ComponentProps, @@ -160,9 +159,28 @@ export const ComboboxContent = forwardRef< commandProps?: ComponentPropsWithoutRef; } >((props, ref) => { + const { onOpenChange, setValues, multiselect } = useContext(ComboboxContext); return ( - {props.children} + { + if (typeof selectedValue !== "string") return; + props.commandProps?.onValueChange?.(selectedValue, info); + setValues((oldValues) => { + if (oldValues.includes(selectedValue)) { + return oldValues.filter((v) => v !== selectedValue); + } + const newValuesSet = new Set( + multiselect ? [...oldValues, selectedValue] : [selectedValue], + ); + return Array.from(newValuesSet); + }); + if (info.fromShortcut || !multiselect) onOpenChange?.(false); + }} + > + {props.children} + ); }); @@ -231,4 +249,3 @@ export const ComboboxSelected = ( const { selected } = useContext(ComboboxItemContext); return
; }; -export const ComboboxShortcut = CommandShortcut; diff --git a/packages/ui/Command.tsx b/packages/ui/Command.tsx index 937bb5d..4e915c6 100644 --- a/packages/ui/Command.tsx +++ b/packages/ui/Command.tsx @@ -1,23 +1,68 @@ -import { forwardRef, ElementRef, ComponentPropsWithoutRef, HTMLAttributes } from "react"; +import { + forwardRef, + ElementRef, + ComponentPropsWithoutRef, + createContext, + useContext, + useState, + useEffect, +} from "react"; import { DialogProps } from "@radix-ui/react-dialog"; import { BsSearch } from "@flowdev/icons"; import { Command as CommandPrimitive } from "cmdk"; import { tw } from "./tw"; import { Dialog, DialogContent } from "./Dialog"; +import { Shortcut } from "./Shortcut"; + +type CommandContextType = { + value: string | undefined; + select: (value: string) => void; +}; +const CommandContext = createContext({ + value: undefined, + select: () => {}, +}); +const useCommandContext = () => useContext(CommandContext); export const Command = forwardRef< ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + Omit, "onValueChange"> & { + onValueChange?: ( + value: string, + info: { + fromShortcut?: boolean; + }, + ) => void; + } +>(({ className, ...props }, ref) => { + const [value, setValue] = useState(props.value); + + useEffect(() => { + setValue(props.value); + }, [props.value]); + + const select = (value: string, info?: { fromShortcut?: boolean }) => { + setValue(value); + props.onValueChange?.(value, info ?? {}); + }; + + return ( + select(value, { fromShortcut: true }) }} + > + + + ); +}); interface CommandDialogProps extends DialogProps {} @@ -95,27 +140,36 @@ export const CommandSeparator = forwardRef< /** * ❗️Note: The value of the `CommandItem` component is case-sensitive as the cmdk package was patched not to lowercase the values. - * It is recommended to create a mapping function from the label to the value. + * ~~It is recommended to create a mapping function from the label to the value.~~ + * Use the `filter` prop of the `Command` component to filter the items. */ export const CommandItem = forwardRef< ElementRef, - ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); + ComponentPropsWithoutRef & { + shortcut?: string; + } +>(({ className, ...props }, ref) => { + const command = useCommandContext(); + const handleShortcutTrigger = (_e: Mousetrap.ExtendedKeyboardEvent, _combo: string) => { + const value = props.value ?? (typeof props.children === "string" ? props.children : undefined); + value && command.select(value); + }; -export const CommandShortcut = ({ className, ...props }: HTMLAttributes) => { return ( - + > + {props.children} + {props.shortcut && ( + + {props.shortcut} + + )} + ); -}; +}); diff --git a/packages/ui/DropdownMenu.tsx b/packages/ui/DropdownMenu.tsx index 5a354e1..1c7ccb0 100644 --- a/packages/ui/DropdownMenu.tsx +++ b/packages/ui/DropdownMenu.tsx @@ -161,12 +161,3 @@ export const DropdownMenuSeparator = React.forwardRef< >(({ className, ...props }, ref) => ( )); - -export const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; diff --git a/packages/ui/Shortcut.tsx b/packages/ui/Shortcut.tsx new file mode 100644 index 0000000..56cb1d3 --- /dev/null +++ b/packages/ui/Shortcut.tsx @@ -0,0 +1,66 @@ +import { HTMLAttributes, useEffect } from "react"; +import { tw } from "./tw"; +import Mousetrap from "mousetrap"; + +export { Mousetrap }; + +const mousetrap = new Mousetrap(); + +function isMac() { + const platform = + navigator.platform ?? (navigator as any)["userAgentData"]?.platform ?? navigator.userAgent; + return /mac/i.test(platform); +} + +const macKeysMap = { + meta: "⌘", + mod: "⌘", + ctrl: "⌃", + alt: "⌥", + shift: "shift", +}; + +const windowsKeysMap: Record = { + meta: "Ctrl", + mod: "◆", + ctrl: "Ctrl", + alt: "Alt", + shift: "Shift", +}; + +export const Shortcut = ( + props: Omit, "children"> & { + /** Whether the shortcuts should be enabled when focus is on an input-like element. */ + includeInputs?: boolean; + children: string; + onTrigger?: (e: Mousetrap.ExtendedKeyboardEvent, combo: string) => void; + }, +) => { + const mac = isMac(); + const keysMap = mac ? macKeysMap : windowsKeysMap; + const childrenTranslated = props.children + .replace(/(mod|ctrl|alt|shift)/g, (match) => { + return keysMap[match as keyof typeof keysMap]; + }) + .replace(/\+/g, " + "); + mousetrap.stopCallback = (e, element) => { + const includeInputs = typeof props?.includeInputs === "undefined" || props.includeInputs; + return !includeInputs; // when true, we don't want to stop the callback + }; + + useEffect(() => { + mousetrap.bind(props.children, (e, combo) => { + props.onTrigger?.(e, combo); + }); + + return () => { + mousetrap.unbind(props.children); + }; + }, [props.children, props.onTrigger]); + + return ( + + {childrenTranslated} + + ); +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index 0e07385..87262fa 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,6 +19,7 @@ "clsx": "^1.2.1", "cmdk": "0.2.0", "framer-motion": "^10.12.10", + "mousetrap": "^1.6.5", "react-hot-toast": "^2.4.1", "react-select": "^5.7.7", "react-timezone-select": "^2.1.2", diff --git a/think.md b/think.md new file mode 100644 index 0000000..9f1186b --- /dev/null +++ b/think.md @@ -0,0 +1,7 @@ +> Just a place to write down some thoughts + +# Keyboard shortcuts + +- Looked at using [react-shortcuts](https://www.npmjs.com/package/react-shortcuts) but it's written in JS and doesn't have TypeScript types (no @types/react-shortcuts). +- Looked into react-hotkeys and react-hotkeys-hook, which are better, but still not what I want. +- I could use react-hotkeys-hook and use the `useHotkeys` hook globally, and have a separate react context that keeps track of which element is in focus, then combining the 2. \ No newline at end of file