diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/KeyboardShortcuts.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/KeyboardShortcuts.tsx index 4466fcf4..66040208 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/KeyboardShortcuts.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/KeyboardShortcuts.tsx @@ -53,12 +53,24 @@ export const KeyboardShortcuts = Extension.create({ if (this.editor.isActive("table")) { return this.editor.chain().focus().goToNextCell().run(); } + if (this.editor.isActive("orderedList") || this.editor.isActive("bulletList")) { + this.editor.chain().focus().sinkListItem("listItem").run(); + return true; + } return false; }, "Shift-Tab": () => { if (this.editor.isActive("table")) { return this.editor.chain().focus().goToPreviousCell().run(); } + if (this.editor.isActive("orderedList") || this.editor.isActive("bulletList")) { + const { $anchor } = this.editor.state.selection; + const isNested = $anchor.node($anchor.depth - 3)?.type.name === "listItem"; + if (isNested) { + return this.editor.chain().focus().liftListItem("listItem").run(); + } + return true; + } return false; }, "Mod-r": () => { diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx index 0d65e360..4baf4aea 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx @@ -6,6 +6,7 @@ import { Heading01Icon, Heading02Icon, LeftToRightListBulletIcon, + LeftToRightListNumberIcon, CheckmarkSquare04Icon, SourceCodeIcon, QuoteUpIcon, @@ -75,6 +76,14 @@ const getSlashCommands = (t: (key: string) => string): SlashCommandItem[] => [ editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, + { + title: t("editor.orderedList"), + description: t("editor.createOrderedList"), + icon: , + command: ({ editor, range }) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, { title: t("editor.taskList"), description: t("editor.createTaskList"), diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/SyntaxHighlightedEditor.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/SyntaxHighlightedEditor.tsx index 5d197004..cd894457 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/SyntaxHighlightedEditor.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/SyntaxHighlightedEditor.tsx @@ -135,7 +135,9 @@ export const SyntaxHighlightedEditor = ({ e.preventDefault(); onLinkRequest?.(textarea.selectionStart !== textarea.selectionEnd); } else if (e.key === "Enter" && !isMod && !e.shiftKey && !e.altKey) { - const newContent = MarkdownUtils.handleBulletListEnter(textarea); + const newContent = + MarkdownUtils.handleBulletListEnter(textarea) ?? + MarkdownUtils.handleOrderedListEnter(textarea); if (newContent !== null) { e.preventDefault(); const { scrollTop, scrollLeft, selectionStart, selectionEnd } = diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/ListMenuDropdown.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/ListMenuDropdown.tsx new file mode 100644 index 00000000..49a3b5de --- /dev/null +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/ListMenuDropdown.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { Editor, useEditorState } from "@tiptap/react"; +import { + ArrowDown01Icon, + LeftToRightListBulletIcon, + LeftToRightListNumberIcon, + TextIndentMoreIcon, + TextIndentLessIcon, +} from "hugeicons-react"; +import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; +import { ToolbarDropdown } from "./ToolbarDropdown"; +import { useTranslations } from "next-intl"; +import * as MarkdownUtils from "@/app/_utils/markdown-editor-utils"; + +interface ListMenuDropdownProps { + editor: Editor | null; + isMarkdownMode: boolean; + onMarkdownChange?: (content: string) => void; + listState: { + isInList: boolean; + isNested: boolean; + isInBulletList: boolean; + isInOrderedList: boolean; + currentItemIsEmpty: boolean; + }; +} + +export const ListMenuDropdown = ({ + editor, + isMarkdownMode, + onMarkdownChange, + listState, +}: ListMenuDropdownProps) => { + const t = useTranslations(); + + const canSink = useEditorState({ + editor, + selector: ({ editor: e }) => (e ? e.can().sinkListItem("listItem") : false), + }) ?? false; + + const canLift = useEditorState({ + editor, + selector: ({ editor: e }) => (e ? e.can().liftListItem("listItem") : false), + }) ?? false; + + if (!editor) return null; + + const applyMarkdown = (fn: (ta: HTMLTextAreaElement) => string) => { + const textarea = document.getElementById( + "markdown-editor-textarea" + ) as HTMLTextAreaElement; + if (textarea && onMarkdownChange) { + const scrollTop = textarea.scrollTop; + const scrollLeft = textarea.scrollLeft; + const newContent = fn(textarea); + const selectionStart = textarea.selectionStart; + const selectionEnd = textarea.selectionEnd; + onMarkdownChange(newContent); + requestAnimationFrame(() => { + const ta = document.getElementById( + "markdown-editor-textarea" + ) as HTMLTextAreaElement; + if (ta) { + ta.focus({ preventScroll: true }); + ta.setSelectionRange(selectionStart, selectionEnd); + ta.scrollTop = scrollTop; + ta.scrollLeft = scrollLeft; + } + }); + } + }; + + const getCurrentListType = (): "bulletList" | "orderedList" | null => { + const { $from } = editor.state.selection; + for (let d = $from.depth; d > 0; d--) { + const name = $from.node(d).type.name; + if (name === "bulletList" || name === "orderedList") return name; + } + return null; + }; + + const liftOutOfList = () => { + let safety = 20; + while ( + safety-- > 0 && + (editor.isActive("bulletList") || editor.isActive("orderedList")) + ) { + if (!editor.chain().focus().liftListItem("listItem").run()) break; + } + }; + + const handleBulletList = () => { + if (isMarkdownMode) { + applyMarkdown(MarkdownUtils.insertBulletList); + return; + } + if (getCurrentListType() === "bulletList") { + liftOutOfList(); + } else { + editor.chain().focus().toggleBulletList().run(); + } + }; + + const handleOrderedList = () => { + if (isMarkdownMode) { + applyMarkdown(MarkdownUtils.insertOrderedList); + return; + } + if (getCurrentListType() === "orderedList") { + liftOutOfList(); + } else { + editor.chain().focus().toggleOrderedList().run(); + } + }; + + const handleIndent = () => { + if (isMarkdownMode) { + applyMarkdown(MarkdownUtils.indentLines); + return; + } + editor.chain().focus().sinkListItem("listItem").run(); + }; + + const handleOutdent = () => { + if (isMarkdownMode) { + applyMarkdown(MarkdownUtils.outdentLines); + return; + } + editor.chain().focus().liftListItem("listItem").run(); + }; + + const isActive = listState.isInList; + + const trigger = ( + + ); + + return ( + +
+ + +
+ + +
+ + ); +}; diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/TipTapToolbar.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/TipTapToolbar.tsx index de647511..4cc654a2 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/TipTapToolbar.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/TipTapToolbar.tsx @@ -1,4 +1,4 @@ -import { Editor } from "@tiptap/react"; +import { Editor, useEditorState } from "@tiptap/react"; import { TextBoldIcon, TextItalicIcon, @@ -13,13 +13,13 @@ import { Tv02Icon, TextUnderlineIcon, Image02Icon, - LeftToRightListBulletIcon, } from "hugeicons-react"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { FileModal } from "@/app/_components/GlobalComponents/Modals/FilesModal/FileModal"; import { ImageSizeModal } from "@/app/_components/GlobalComponents/Modals/ImageSizeModal"; import { CodeBlockDropdown } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/CodeBlocksDropdown"; import { DiagramsDropdown } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/DiagramsDropdown"; +import { ListMenuDropdown } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/ListMenuDropdown"; import { TableInsertModal } from "@/app/_components/FeatureComponents/Notes/Parts/Table/TableInsertModal"; import { FontFamilyDropdown } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/Toolbar/FontFamilyDropdown"; import { useState, useEffect } from "react"; @@ -100,6 +100,29 @@ export const TiptapToolbar = ({ } }, [linkRequestPending, linkRequestHasSelection, isMarkdownMode, editor, onLinkRequestHandled]); + const listState = useEditorState({ + editor, + selector: ({ editor: e }) => { + if (!e) return { isInList: false, isNested: false, isInBulletList: false, isInOrderedList: false, currentItemIsEmpty: false }; + const isInBulletList = e.isActive('bulletList'); + const isInOrderedList = e.isActive('orderedList'); + const isInList = isInBulletList || isInOrderedList; + let isNested = false; + if (isInList) { + const { $anchor } = e.state.selection; + outer: for (let d = $anchor.depth; d >= 0; d--) { + if ($anchor.node(d).type.name === 'listItem') { + for (let d2 = d - 1; d2 >= 0; d2--) { + if ($anchor.node(d2).type.name === 'listItem') { isNested = true; break outer; } + } + break; + } + } + } + return { isInList, isNested, isInBulletList, isInOrderedList, currentItemIsEmpty: e.state.selection.$anchor.parent.textContent === '' }; + }, + }) ?? { isInList: false, isNested: false, isInBulletList: false, isInOrderedList: false, currentItemIsEmpty: false }; + if (!editor) { return null; } @@ -459,20 +482,12 @@ export const TiptapToolbar = ({ > - +