fixed indent/outdent
This commit is contained in:
@@ -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 { createTiptapEditor } from "solid-tiptap";
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Indent, Outdent } from "lucide-solid";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
import { SlashCommands } from "@/lib/slash-command";
|
import { SlashCommands } from "@/lib/slash-command";
|
||||||
import { suggestion } from "@/lib/slash-renderer";
|
import { suggestion } from "@/lib/slash-renderer";
|
||||||
|
import { MobileIndent } from "@/lib/mobile-indent";
|
||||||
|
|
||||||
interface TaskEditorProps {
|
interface TaskEditorProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -21,7 +20,6 @@ interface TaskEditorProps {
|
|||||||
|
|
||||||
export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
||||||
let editorRef: HTMLDivElement | undefined;
|
let editorRef: HTMLDivElement | undefined;
|
||||||
const [isFocused, setIsFocused] = createSignal(false);
|
|
||||||
|
|
||||||
const editor = createTiptapEditor(() => ({
|
const editor = createTiptapEditor(() => ({
|
||||||
element: editorRef!,
|
element: editorRef!,
|
||||||
@@ -55,6 +53,7 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
SlashCommands.configure({
|
SlashCommands.configure({
|
||||||
suggestion,
|
suggestion,
|
||||||
}),
|
}),
|
||||||
|
MobileIndent,
|
||||||
],
|
],
|
||||||
// Use untrack so the editor doesn't re-initialize when props.content changes
|
// Use untrack so the editor doesn't re-initialize when props.content changes
|
||||||
content: untrack(() => props.content) || "",
|
content: untrack(() => props.content) || "",
|
||||||
@@ -62,11 +61,6 @@ export const TaskEditor: Component<TaskEditorProps> = (props) => {
|
|||||||
onUpdate: ({ editor }) => {
|
onUpdate: ({ editor }) => {
|
||||||
props.onUpdate(editor.getHTML());
|
props.onUpdate(editor.getHTML());
|
||||||
},
|
},
|
||||||
onFocus: () => setIsFocused(true),
|
|
||||||
onBlur: () => {
|
|
||||||
// Delay blur to allow button clicks
|
|
||||||
setTimeout(() => setIsFocused(false), 200);
|
|
||||||
},
|
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
class: cn(
|
class: cn(
|
||||||
@@ -103,66 +97,11 @@ export const TaskEditor: Component<TaskEditorProps> = (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 (
|
return (
|
||||||
<div class="relative w-full">
|
<div
|
||||||
<div
|
ref={editorRef}
|
||||||
ref={editorRef}
|
class="w-full cursor-text"
|
||||||
class="w-full cursor-text"
|
onClick={() => editor()?.chain().focus().run()}
|
||||||
onClick={() => editor()?.chain().focus().run()}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Mobile Indentation Toolbar */}
|
|
||||||
<Show when={isFocused() && props.editable !== false}>
|
|
||||||
<div class="md:hidden fixed bottom-[env(safe-area-inset-bottom,0px)] left-0 right-0 z-[100] bg-background/95 backdrop-blur-md border-t border-border px-4 py-2 flex items-center justify-center gap-6 animate-in slide-in-from-bottom duration-200">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-10 w-12 flex flex-col items-center gap-0.5 text-muted-foreground active:scale-95 active:bg-muted"
|
|
||||||
onMouseDown={handleOutdent}
|
|
||||||
>
|
|
||||||
<Outdent size={18} />
|
|
||||||
<span class="text-[9px] font-bold uppercase tracking-tighter">Outdent</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div class="w-px h-6 bg-border/50" />
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-10 w-12 flex flex-col items-center gap-0.5 text-muted-foreground active:scale-95 active:bg-muted"
|
|
||||||
onMouseDown={handleIndent}
|
|
||||||
>
|
|
||||||
<Indent size={18} />
|
|
||||||
<span class="text-[9px] font-bold uppercase tracking-tighter">Indent</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export interface ButtonProps extends VariantProps<typeof buttonVariants> {
|
|||||||
onClick?: (e: MouseEvent) => void
|
onClick?: (e: MouseEvent) => void
|
||||||
onMouseEnter?: (e: MouseEvent) => void
|
onMouseEnter?: (e: MouseEvent) => void
|
||||||
onMouseLeave?: (e: MouseEvent) => void
|
onMouseLeave?: (e: MouseEvent) => void
|
||||||
onMouseDown?: (e: MouseEvent) => void
|
|
||||||
children?: JSX.Element
|
children?: JSX.Element
|
||||||
size?: "default" | "sm" | "lg" | "icon"
|
size?: "default" | "sm" | "lg" | "icon"
|
||||||
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user