diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 4b584b5..d9d8293 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -1,15 +1,14 @@ -import { type Component, createEffect, untrack, Show, createSignal } from "solid-js"; +import { type Component, createEffect, untrack } from "solid-js"; import { createTiptapEditor } from "solid-tiptap"; import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; import { cn } from "@/lib/utils"; -import { Indent, Outdent } from "lucide-solid"; -import { Button } from "./ui/button"; import { SlashCommands } from "@/lib/slash-command"; import { suggestion } from "@/lib/slash-renderer"; +import { MobileIndent } from "@/lib/mobile-indent"; interface TaskEditorProps { content?: string; @@ -21,7 +20,6 @@ interface TaskEditorProps { export const TaskEditor: Component = (props) => { let editorRef: HTMLDivElement | undefined; - const [isFocused, setIsFocused] = createSignal(false); const editor = createTiptapEditor(() => ({ element: editorRef!, @@ -55,6 +53,7 @@ export const TaskEditor: Component = (props) => { SlashCommands.configure({ suggestion, }), + MobileIndent, ], // Use untrack so the editor doesn't re-initialize when props.content changes content: untrack(() => props.content) || "", @@ -62,11 +61,6 @@ export const TaskEditor: Component = (props) => { onUpdate: ({ editor }) => { props.onUpdate(editor.getHTML()); }, - onFocus: () => setIsFocused(true), - onBlur: () => { - // Delay blur to allow button clicks - setTimeout(() => setIsFocused(false), 200); - }, editorProps: { attributes: { class: cn( @@ -103,66 +97,11 @@ export const TaskEditor: Component = (props) => { } }); - const handleIndent = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const eInstance = editor(); - if (!eInstance) return; - - if (eInstance.can().sinkListItem('taskItem')) { - eInstance.chain().focus().sinkListItem('taskItem').run(); - } else if (eInstance.can().sinkListItem('listItem')) { - eInstance.chain().focus().sinkListItem('listItem').run(); - } - }; - - const handleOutdent = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const eInstance = editor(); - if (!eInstance) return; - - if (eInstance.can().liftListItem('taskItem')) { - eInstance.chain().focus().liftListItem('taskItem').run(); - } else if (eInstance.can().liftListItem('listItem')) { - eInstance.chain().focus().liftListItem('listItem').run(); - } - }; - return ( -
-
editor()?.chain().focus().run()} - /> - - {/* Mobile Indentation Toolbar */} - -
- - -
- - -
- -
+
editor()?.chain().focus().run()} + /> ); }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 1d6d808..8570c5f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -34,7 +34,6 @@ export interface ButtonProps extends VariantProps { onClick?: (e: MouseEvent) => void onMouseEnter?: (e: MouseEvent) => void onMouseLeave?: (e: MouseEvent) => void - onMouseDown?: (e: MouseEvent) => void children?: JSX.Element size?: "default" | "sm" | "lg" | "icon" variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" diff --git a/src/lib/mobile-indent.ts b/src/lib/mobile-indent.ts new file mode 100644 index 0000000..f18f778 --- /dev/null +++ b/src/lib/mobile-indent.ts @@ -0,0 +1,97 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +/** + * MobileIndent Extension + * + * Provides mobile-friendly indent/outdent by: + * 1. Converting a space at the start of a line into an indent action (Tab) + * 2. Converting a backspace at the start of an indented list item into an outdent action (Shift+Tab) + */ +export const MobileIndent = Extension.create({ + name: 'mobileIndent', + + addProseMirrorPlugins() { + const editor = this.editor; + + return [ + new Plugin({ + key: new PluginKey('mobileIndent'), + props: { + handleTextInput(view, _from, _to, text) { + // Only care about space + if (text !== ' ') return false; + + const { state } = view; + const { $from } = state.selection; + + // Check if we're at the start of a text block (position 0 of the parent) + // $from.parentOffset is the offset within the current parent node + if ($from.parentOffset !== 0) return false; + + // Check if we're in a list item (taskItem or listItem) + const parentNode = $from.node(-1); + if (!parentNode) return false; + + const parentType = parentNode.type.name; + const isListItem = parentType === 'taskItem' || parentType === 'listItem'; + + if (!isListItem) return false; + + // Sink the list item (indent) + const sunk = editor.chain().sinkListItem('taskItem').run() || + editor.chain().sinkListItem('listItem').run(); + + // Return true to prevent the space from being inserted + return sunk; + }, + + handleKeyDown(view, event) { + // Only care about Backspace + if (event.key !== 'Backspace') return false; + + const { state } = view; + const { $from, empty } = state.selection; + + // Only handle if selection is collapsed (cursor, not a range) + if (!empty) return false; + + // Check if we're at the start of a text block + if ($from.parentOffset !== 0) return false; + + // Check if we're in a list item + const parentNode = $from.node(-1); + if (!parentNode) return false; + + const parentType = parentNode.type.name; + const isListItem = parentType === 'taskItem' || parentType === 'listItem'; + + if (!isListItem) return false; + + // Check if this list item is nested (has a parent list that is itself in a list item) + // We only want to lift if we are nested + const grandparent = $from.node(-2); + const greatGrandparent = $from.node(-3); + + // If grandparent is a list and greatGrandparent is also a list item, we're nested + const grandparentIsList = grandparent?.type.name === 'taskList' || grandparent?.type.name === 'bulletList' || grandparent?.type.name === 'orderedList'; + const greatGrandparentIsListItem = greatGrandparent?.type.name === 'taskItem' || greatGrandparent?.type.name === 'listItem'; + + if (!grandparentIsList || !greatGrandparentIsListItem) return false; + + // Lift the list item (outdent) + const lifted = editor.chain().liftListItem('taskItem').run() || + editor.chain().liftListItem('listItem').run(); + + if (lifted) { + event.preventDefault(); + return true; + } + + return false; + }, + }, + }), + ]; + }, +});