diff --git a/q&a b/q&a deleted file mode 100644 index e69de29b..00000000 diff --git a/web/app.config.ts b/web/app.config.ts index 6e0bb830..6067f716 100644 --- a/web/app.config.ts +++ b/web/app.config.ts @@ -3,7 +3,7 @@ import tsConfigPaths from "vite-tsconfig-paths" export default defineConfig({ vite: { - plugins: () => [ + plugins: [ tsConfigPaths({ projects: ["./tsconfig.json"], }), diff --git a/web/app/components/DefaultCatchBoundary.tsx b/web/app/components/DefaultCatchBoundary.tsx index ebb1259f..3ed2e4a8 100644 --- a/web/app/components/DefaultCatchBoundary.tsx +++ b/web/app/components/DefaultCatchBoundary.tsx @@ -17,28 +17,28 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) { console.error(error) return ( -
+
-
+
{isRoot ? ( Home ) : ( { e.preventDefault() window.history.back() diff --git a/web/app/components/GlobalKeyboardHandler.tsx b/web/app/components/GlobalKeyboardHandler.tsx index 83861309..158c422c 100644 --- a/web/app/components/GlobalKeyboardHandler.tsx +++ b/web/app/components/GlobalKeyboardHandler.tsx @@ -1,13 +1,13 @@ import * as React from "react" import { useKeyDown, KeyFilter, Options } from "@/hooks/use-key-down" import { useAccountOrGuest } from "@/lib/providers/jazz-provider" -import { isModKey, isServer } from "@/lib/utils" import { useAtom } from "jotai" import { usePageActions } from "~/hooks/actions/use-page-actions" import { useAuth } from "@clerk/tanstack-start" import { useNavigate } from "@tanstack/react-router" import queryString from "query-string" import { commandPaletteOpenAtom } from "~/store/any-store" +import { isModKey, isServer } from "@shared/utils" type RegisterKeyDownProps = { trigger: KeyFilter diff --git a/web/app/components/NotFound.tsx b/web/app/components/NotFound.tsx index b29bf8dc..a218ccf7 100644 --- a/web/app/components/NotFound.tsx +++ b/web/app/components/NotFound.tsx @@ -6,16 +6,16 @@ export function NotFound({ children }: { children?: any }) {
{children ||

The page you are looking for does not exist.

}
-

+

Start Over diff --git a/web/app/components/Onboarding.tsx b/web/app/components/Onboarding.tsx index d516bde7..922bb0b8 100644 --- a/web/app/components/Onboarding.tsx +++ b/web/app/components/Onboarding.tsx @@ -42,7 +42,7 @@ export function Onboarding() { -

+

Learn Anything is a learning platform that organizes knowledge in a social way. You can create pages, add links, track learning diff --git a/web/app/components/command-palette/command-data.ts b/web/app/components/command-palette/command-data.ts index bf1e4344..e8d04735 100644 --- a/web/app/components/command-palette/command-data.ts +++ b/web/app/components/command-palette/command-data.ts @@ -1,7 +1,7 @@ import { icons } from "lucide-react" import { LaAccount } from "@/lib/schema" import { HTMLLikeElement } from "@/lib/utils" -import { useCommandActions } from "~/hooks/use-command-actions" +import { useCommandActions } from "~/hooks/actions/use-command-actions" export type CommandAction = string | (() => void) diff --git a/web/app/components/command-palette/command-palette.tsx b/web/app/components/command-palette/command-palette.tsx index d9b064ea..4dce29dc 100644 --- a/web/app/components/command-palette/command-palette.tsx +++ b/web/app/components/command-palette/command-palette.tsx @@ -12,7 +12,7 @@ import { CommandGroup } from "./command-group" import { CommandAction, createCommandGroups } from "./command-data" import { useAccount, useAccountOrGuest } from "@/lib/providers/jazz-provider" import { useAtom } from "jotai" -import { useCommandActions } from "~/hooks/use-command-actions" +import { useCommandActions } from "~/hooks/actions/use-command-actions" import { filterItems, getTopics, diff --git a/web/app/components/custom/content-header.tsx b/web/app/components/custom/content-header.tsx index d9e48dd0..f1799c24 100644 --- a/web/app/components/custom/content-header.tsx +++ b/web/app/components/custom/content-header.tsx @@ -15,7 +15,7 @@ export const ContentHeader = React.forwardRef< return (

{ size="icon" variant="ghost" aria-label="Menu" - className="text-primary/60" + className="-ml-2 cursor-default text-muted-foreground hover:bg-transparent" onClick={handleClick} > diff --git a/web/app/components/custom/learning-state-selector.tsx b/web/app/components/custom/learning-state-selector.tsx index 3f5286d9..8cfde39a 100644 --- a/web/app/components/custom/learning-state-selector.tsx +++ b/web/app/components/custom/learning-state-selector.tsx @@ -66,7 +66,7 @@ export const LearningStateSelector: React.FC = ({ type="button" role="combobox" variant="secondary" - className={cn("gap-x-2 text-sm", className)} + className={cn("h-7 gap-x-2 text-sm", className)} > {iconName && ( + {({ isActive }) => ( + <> +
+ + {title} +
+ + {count > 0 && } + + )} + + ) +} + +interface BadgeCountProps { + count: number + isActive: boolean +} + +function BadgeCount({ count, isActive }: BadgeCountProps) { + return ( + + {count} + + ) +} diff --git a/web/app/components/custom/textarea-autosize.tsx b/web/app/components/custom/textarea-autosize.tsx index e2eed8e3..4c582b19 100644 --- a/web/app/components/custom/textarea-autosize.tsx +++ b/web/app/components/custom/textarea-autosize.tsx @@ -10,7 +10,7 @@ const TextareaAutosize = React.forwardRef( return ( ) => { + return ( + + + + ) +} diff --git a/web/app/components/shortcut/shortcut.tsx b/web/app/components/shortcut/shortcut.tsx index f7b19e25..e3384c76 100644 --- a/web/app/components/shortcut/shortcut.tsx +++ b/web/app/components/shortcut/shortcut.tsx @@ -66,7 +66,7 @@ const ShortcutKey: React.FC<{ keyChar: string }> = ({ keyChar }) => ( const ShortcutItem: React.FC = ({ label, keys, then }) => (
- {label} + {label}
@@ -79,7 +79,7 @@ const ShortcutItem: React.FC = ({ label, keys, then }) => ( ))} {then && ( <> - then + then {then.map((key, index) => ( ))} @@ -149,7 +149,7 @@ export function Shortcut() { "size-6 p-0", )} > - + Close
@@ -158,12 +158,12 @@ export function Shortcut() {
setSearchQuery(e.target.value)} /> diff --git a/web/app/components/sidebar/partials/feedback.tsx b/web/app/components/sidebar/partials/feedback.tsx index ec2db9c9..100505dc 100644 --- a/web/app/components/sidebar/partials/feedback.tsx +++ b/web/app/components/sidebar/partials/feedback.tsx @@ -109,7 +109,7 @@ export function Feedback() { @@ -134,7 +134,7 @@ export function Feedback() { {...field} throttleDelay={500} className={cn( - "border-muted-foreground/40 focus-within:border-muted-foreground/80 min-h-52 rounded-lg", + "min-h-52 rounded-lg border-muted-foreground/40 focus-within:border-muted-foreground/80", { "border-destructive focus-within:border-destructive": form.formState.errors.content, diff --git a/web/app/components/sidebar/partials/journal-section.tsx b/web/app/components/sidebar/partials/journal-section.tsx index acedfe6b..26120a65 100644 --- a/web/app/components/sidebar/partials/journal-section.tsx +++ b/web/app/components/sidebar/partials/journal-section.tsx @@ -75,7 +75,7 @@ const JournalSectionHeader: React.FC = ({

Journal {entriesCount > 0 && ( - ({entriesCount}) + ({entriesCount}) )}

@@ -104,7 +104,7 @@ const JournalEntryItem: React.FC = ({ entry }) => ( href={`/journal/${entry.id}`} className="group/journal-entry relative flex min-w-0 flex-1" > -
+

{ + const { me } = useAccount({ + root: { + personalLinks: [], + topicsWantToLearn: [], + topicsLearning: [], + topicsLearned: [], + }, + }) + + const linkCount = me?.root.personalLinks?.length || 0 + + const topicCount = + (me?.root.topicsWantToLearn?.length || 0) + + (me?.root.topicsLearning?.length || 0) + + (me?.root.topicsLearned?.length || 0) + + return ( +

+ + +
+ ) +} diff --git a/web/app/components/sidebar/partials/link-section.tsx b/web/app/components/sidebar/partials/link-section.tsx index a100210a..0443f6ff 100644 --- a/web/app/components/sidebar/partials/link-section.tsx +++ b/web/app/components/sidebar/partials/link-section.tsx @@ -4,6 +4,7 @@ import { useAccount } from "@/lib/providers/jazz-provider" import { cn } from "@/lib/utils" import { PersonalLinkLists } from "@/lib/schema/personal-link" import { LearningStateValue } from "~/lib/constants" +import { LaIcon } from "~/components/custom/la-icon" export const LinkSection: React.FC = () => { const { me } = useAccount({ root: { personalLinks: [] } }) @@ -13,7 +14,7 @@ export const LinkSection: React.FC = () => { const linkCount = me.root.personalLinks?.length || 0 return ( -
+
@@ -24,22 +25,41 @@ interface LinkSectionHeaderProps { linkCount: number } -const LinkSectionHeader: React.FC = ({ linkCount }) => ( - - Links - {linkCount > 0 && ( - {linkCount} - )} - -) +const LinkSectionHeader: React.FC = ({ linkCount }) => { + return ( + + {({ isActive }) => { + return ( + <> +
+ + Links +
+ + {linkCount > 0 && ( + + {linkCount} + + )} + + ) + }} + + ) +} interface LinkListProps { personalLinks: PersonalLinkLists @@ -87,29 +107,34 @@ interface LinkListItemProps { } const LinkListItem: React.FC = ({ label, state, count }) => ( -
-
- -
-

- {label} -

-
- - {count > 0 && ( - - {count} - +
+ + activeProps={{ + className: + "bg-[var(--item-active)] data-[status='active']:hover:bg-[var(--item-active)]", + }} + > + {({ isActive }) => ( + <> +
+

{label}

+
+ {count > 0 && ( + + {count} + + )} + + )} +
) diff --git a/web/app/components/sidebar/partials/page-section.tsx b/web/app/components/sidebar/partials/page-section.tsx index 3e92f2c3..8398dda1 100644 --- a/web/app/components/sidebar/partials/page-section.tsx +++ b/web/app/components/sidebar/partials/page-section.tsx @@ -20,6 +20,7 @@ import { } from "@/components/ui/dropdown-menu" import { usePageActions } from "~/hooks/actions/use-page-actions" import { icons } from "lucide-react" +import { ArrowIcon } from "~/components/icons/arrow-icon" type SortOption = "title" | "recent" type ShowOption = 5 | 10 | 15 | 20 | 0 @@ -44,56 +45,82 @@ const SHOWS: Option[] = [ const pageSortAtom = atomWithStorage("pageSort", "title") const pageShowAtom = atomWithStorage("pageShow", 5) +const isExpandedAtom = atomWithStorage("isPageSectionExpanded", true) export const PageSection: React.FC = () => { - const { me } = useAccount({ - root: { - personalPages: [], - }, - }) + const { me } = useAccount({ root: { personalPages: [] } }) const [sort] = useAtom(pageSortAtom) const [show] = useAtom(pageShowAtom) + const [isExpanded, setIsExpanded] = useAtom(isExpandedAtom) if (!me) return null const pageCount = me.root.personalPages?.length || 0 return ( -
- - +
+ setIsExpanded(!isExpanded)} + /> + {isExpanded && ( + + )}
) } interface PageSectionHeaderProps { pageCount: number + isExpanded: boolean + onToggle: () => void } -const PageSectionHeader: React.FC = ({ pageCount }) => ( - = ({ + pageCount, + isExpanded, + onToggle, +}) => ( +
-
-

- Pages - {pageCount > 0 && ( - {pageCount} - )} -

+ +
- +
) const NewPageButton: React.FC = () => { @@ -122,11 +149,11 @@ const NewPageButton: React.FC = () => { variant="ghost" aria-label="New Page" className={cn( - "flex size-5 items-center justify-center p-0.5 shadow-none", - "hover:bg-accent-foreground/10", + "flex size-5 cursor-default items-center justify-center p-0.5 shadow-none", + "text-muted-foreground hover:bg-inherit hover:text-foreground", "opacity-0 transition-opacity duration-200", "group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100", - "data-[state='open']:opacity-100 focus-visible:outline-none focus-visible:ring-0", + "focus-visible:outline-none focus-visible:ring-0 data-[state='open']:opacity-100", )} onClick={handleClick} > @@ -168,29 +195,31 @@ interface PageListItemProps { const PageListItem: React.FC = ({ page }) => { return ( -
-
- -
- -

- {page.title || "Untitled"} -

-
- -
-
+ + {({ isActive }) => ( +
+ +

{page.title || "Untitled"}

+
+ )} + ) } @@ -212,11 +241,11 @@ const SubMenu = ({ - + {label} - + {options.find((option) => option.value === currentValue)?.label} @@ -251,11 +280,11 @@ const ShowAllForm: React.FC = () => { variant="ghost" size="sm" className={cn( - "flex size-5 items-center justify-center p-0.5 shadow-none", - "hover:bg-accent-foreground/10", + "flex size-5 cursor-default items-center justify-center p-0.5 shadow-none", + "text-muted-foreground hover:bg-inherit hover:text-foreground", "opacity-0 transition-opacity duration-200", "group-hover/pages:opacity-100 group-has-[[data-state='open']]/pages:opacity-100", - "data-[state='open']:opacity-100 focus-visible:outline-none focus-visible:ring-0", + "focus-visible:outline-none focus-visible:ring-0 data-[state='open']:opacity-100", )} > diff --git a/web/app/components/sidebar/partials/profile-section.tsx b/web/app/components/sidebar/partials/profile-section.tsx index 269b3de7..0e02cbc6 100644 --- a/web/app/components/sidebar/partials/profile-section.tsx +++ b/web/app/components/sidebar/partials/profile-section.tsx @@ -56,6 +56,7 @@ export const ProfileSection: React.FC = () => { signOut={signOut} setShowShortcut={setShowShortcut} /> +
@@ -83,12 +84,12 @@ const ProfileDropdown: React.FC = ({
{renderAnswers(answers)}
-
+
- - -
-
- -
- -
-
-
-
- + + + + Display + + + + List + + + + + Ordering + + handleSortChange("title")}> + Title + {sort === "title" && ( + + )} + + handleSortChange("manual")}> + Manual + {sort === "manual" && ( + + )} + + +
) }) +export const LinkHeader: React.FC = React.memo(() => { + const isTablet = useMedia("(max-width: 1024px)") + + return ( + <> + +
+ +
+ + Links + +
+
+ + {!isTablet && } + +
+ + + + + {isTablet && ( +
+ +
+ )} + + ) +}) + +LinkHeader.displayName = "LinkHeader" +LearningTab.displayName = "LearningTab" FilterAndSort.displayName = "FilterAndSort" diff --git a/web/app/routes/_layout/_pages/_protected/links/-item.tsx b/web/app/routes/_layout/_pages/_protected/links/-item.tsx index 4445d3d7..437a9bd5 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-item.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-item.tsx @@ -115,15 +115,15 @@ export const LinkItem = React.forwardRef( data-disabled={disabled} data-active={isActive} className={cn( - "w-full overflow-visible border-b-[0.5px] border-transparent outline-none", - "data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", + "w-full cursor-default overflow-visible border-b-[0.5px] border-transparent outline-none", + "data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", )} onKeyDown={handleKeyDown} >
( size="sm" type="button" role="combobox" - variant="secondary" - className="size-7 shrink-0 p-0" + variant="ghost" + className="size-7 shrink-0 cursor-default p-0 text-muted-foreground/75 hover:bg-inherit hover:text-foreground" onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} > @@ -148,7 +148,7 @@ export const LinkItem = React.forwardRef( className={cn(selectedLearningState.className)} /> ) : ( - + )} @@ -167,22 +167,22 @@ export const LinkItem = React.forwardRef(
-
+
{personalLink.icon && ( {personalLink.title} )} -

+

{personalLink.title}

{personalLink.url && ( -
+
-
+
) }, diff --git a/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx b/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx index 28546103..94a58eca 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-link-form.tsx @@ -19,7 +19,8 @@ import { DescriptionInput } from "./-description-input" import { UrlBadge } from "./-url-badge" import { NotesSection } from "./-notes-section" import { useOnClickOutside } from "~/hooks/use-on-click-outside" -import TopicSelector, { +import { + TopicSelector, topicSelectorAtom, } from "~/components/custom/topic-selector" import { createServerFn } from "@tanstack/start" @@ -291,7 +292,7 @@ export const LinkForm: React.FC = ({ >
@@ -369,7 +370,7 @@ export const LinkForm: React.FC = ({ {isFetching ? (
- + { autoComplete="off" placeholder="Notes" className={cn( - "placeholder:text-muted-foreground/70 border-none pl-8 shadow-none focus-visible:ring-0", + "border-none pl-8 shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0", )} /> diff --git a/web/app/routes/_layout/_pages/_protected/links/-title-input.tsx b/web/app/routes/_layout/_pages/_protected/links/-title-input.tsx index 5377f4bd..f020b2ff 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-title-input.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-title-input.tsx @@ -31,7 +31,7 @@ export const TitleInput: React.FC = ({ urlFetched }) => { maxLength={100} autoFocus placeholder="Title" - className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0" + className="h-8 border-none p-1.5 text-[15px] font-semibold shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0" /> diff --git a/web/app/routes/_layout/_pages/_protected/links/-url-badge.tsx b/web/app/routes/_layout/_pages/_protected/links/-url-badge.tsx index cc21b832..fa856100 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-url-badge.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-url-badge.tsx @@ -27,9 +27,9 @@ export const UrlBadge: React.FC = ({ size="icon" type="button" onClick={handleResetUrl} - className="text-muted-foreground hover:text-foreground ml-2 size-4 rounded-full bg-transparent hover:bg-transparent" + className="ml-2 size-4 rounded-full bg-transparent text-muted-foreground hover:bg-transparent hover:text-foreground" > - +
diff --git a/web/app/routes/_layout/_pages/_protected/links/-url-input.tsx b/web/app/routes/_layout/_pages/_protected/links/-url-input.tsx index dfa92906..1d6760ed 100644 --- a/web/app/routes/_layout/_pages/_protected/links/-url-input.tsx +++ b/web/app/routes/_layout/_pages/_protected/links/-url-input.tsx @@ -65,14 +65,14 @@ export const UrlInput: React.FC = ({ maxLength={100} autoFocus placeholder="Paste a link or write a link" - className="placeholder:text-muted-foreground/70 h-8 border-none p-1.5 text-[15px] font-semibold shadow-none focus-visible:ring-0" + className="h-8 border-none p-1.5 text-[15px] font-semibold shadow-none placeholder:text-muted-foreground/70 focus-visible:ring-0" onKeyDown={handleKeyDown} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} /> - + Press Enter to fetch metadata diff --git a/web/app/routes/_layout/_pages/_protected/onboarding/index.tsx b/web/app/routes/_layout/_pages/_protected/onboarding/index.tsx index d3a66a19..ea4371a1 100644 --- a/web/app/routes/_layout/_pages/_protected/onboarding/index.tsx +++ b/web/app/routes/_layout/_pages/_protected/onboarding/index.tsx @@ -60,7 +60,7 @@ const StepItem = ({ done: boolean }) => (
-
+
{number}
diff --git a/web/app/routes/_layout/_pages/_protected/pages/$pageId/-header.tsx b/web/app/routes/_layout/_pages/_protected/pages/$pageId/-header.tsx index 521f887c..3369b7c2 100644 --- a/web/app/routes/_layout/_pages/_protected/pages/$pageId/-header.tsx +++ b/web/app/routes/_layout/_pages/_protected/pages/$pageId/-header.tsx @@ -23,7 +23,7 @@ export const DetailPageHeader: React.FC = ({ return ( <> - +
@@ -45,7 +45,7 @@ export const DetailPageHeader: React.FC = ({ )} />
diff --git a/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx b/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx index b1a0e3f1..50bf4194 100644 --- a/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx +++ b/web/app/routes/_layout/_pages/_protected/pages/$pageId/index.tsx @@ -15,14 +15,14 @@ import { Button } from "@/components/ui/button" import { LaIcon } from "@/components/custom/la-icon" import { useConfirm } from "@omit/react-confirm-dialog" import { usePageActions } from "~/hooks/actions/use-page-actions" -import { Paragraph } from "@shared/la-editor/extensions/paragraph" -import { StarterKit } from "@shared/la-editor/extensions/starter-kit" -import { LAEditor, LAEditorRef } from "@shared/la-editor" +import { Paragraph } from "@shared/editor/extensions/paragraph" +import { StarterKit } from "@shared/editor/extensions/starter-kit" +import { LaEditor } from "@shared/editor" export const Route = createFileRoute( "/_layout/_pages/_protected/pages/$pageId/", )({ - component: () => , + component: PageDetailComponent, }) const TITLE_PLACEHOLDER = "Untitled" @@ -73,20 +73,22 @@ function PageDetailComponent() { ) } -const SidebarActions = ({ - page, - handleDelete, -}: { - page: PersonalPage - handleDelete: () => void -}) => ( -
-
-
- Page actions -
-
-
+const SidebarActions = React.memo( + ({ + page, + handleDelete, + }: { + page: PersonalPage + handleDelete: () => void + }) => ( +
+
+
+ + Page actions + +
+
{ @@ -101,52 +103,40 @@ const SidebarActions = ({ )} /> -
-
-
+ ), ) -const DetailPageForm = ({ page }: { page: PersonalPage }) => { +SidebarActions.displayName = "SidebarActions" + +const DetailPageForm = React.memo(({ page }: { page: PersonalPage }) => { const titleEditorRef = React.useRef(null) - const contentEditorRef = React.useRef(null) - const isTitleInitialMount = React.useRef(true) - const isContentInitialMount = React.useRef(true) - const isInitialFocusApplied = React.useRef(false) + const contentEditorRef = React.useRef(null) const updatePageContent = React.useCallback( - (content: Content, model: PersonalPage) => { - if (isContentInitialMount.current) { - isContentInitialMount.current = false - return - } - model.content = content - model.updatedAt = new Date() + (content: Content) => { + page.content = content + page.updatedAt = new Date() }, - [], + [page], ) const handleUpdateTitle = React.useCallback( (editor: Editor) => { - if (isTitleInitialMount.current) { - isTitleInitialMount.current = false - return - } - const newTitle = editor.getText() if (newTitle !== page.title) { - const slug = generateUniqueSlug(page.title?.toString() || "") + const slug = generateUniqueSlug(newTitle || "") page.title = newTitle page.slug = slug page.updatedAt = new Date() @@ -164,22 +154,18 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const { selection } = state const { $anchor } = selection - switch (event.key) { - case "ArrowRight": - case "ArrowDown": - if ($anchor.pos === state.doc.content.size - 1) { - event.preventDefault() - contentEditorRef.current?.editor?.commands.focus("start") - return true - } - break - case "Enter": - if (!event.shiftKey) { - event.preventDefault() - contentEditorRef.current?.editor?.commands.focus("start") - return true - } - break + if ( + (event.key === "ArrowRight" || event.key === "ArrowDown") && + $anchor.pos === state.doc.content.size - 1 + ) { + event.preventDefault() + contentEditorRef.current?.commands.focus("start") + return true + } + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + contentEditorRef.current?.commands.focus("start") + return true } return false }, @@ -188,7 +174,7 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { const handleContentKeyDown = React.useCallback( (view: EditorView, event: KeyboardEvent) => { - const editor = contentEditorRef.current?.editor + const editor = contentEditorRef.current if (!editor) return false const { state } = editor @@ -239,34 +225,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => { }, onCreate: ({ editor }) => { if (page.title) editor.commands.setContent(`

${page.title}

`) + titleEditorRef.current = editor }, onBlur: ({ editor }) => handleUpdateTitle(editor), onUpdate: ({ editor }) => handleUpdateTitle(editor), }) - React.useEffect(() => { - if (titleEditor) { - titleEditorRef.current = titleEditor - } - }, [titleEditor]) - - React.useEffect(() => { - isTitleInitialMount.current = true - isContentInitialMount.current = true - - if ( - !isInitialFocusApplied.current && - titleEditor && - contentEditorRef.current?.editor - ) { - isInitialFocusApplied.current = true - if (!page.title) { - titleEditor?.commands.focus() - } else { - contentEditorRef.current.editor.commands.focus() + const handleCreate = React.useCallback( + ({ editor }: { editor: Editor }) => { + if (page.content) { + editor.commands.setContent(page.content as Content) } - } - }, [page.title, titleEditor]) + contentEditorRef.current = editor + }, + [page.content], + ) return (
@@ -275,21 +248,21 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
- updatePageContent(c, page)} - handleKeyDown={handleContentKeyDown} - onBlur={(c) => updatePageContent(c, page)} + editorProps={{ handleKeyDown: handleContentKeyDown }} + onCreate={handleCreate} + onUpdate={updatePageContent} + onBlur={updatePageContent} />
@@ -297,4 +270,6 @@ const DetailPageForm = ({ page }: { page: PersonalPage }) => {
) -} +}) + +DetailPageForm.displayName = "DetailPageForm" diff --git a/web/app/routes/_layout/_pages/_protected/pages/-header.tsx b/web/app/routes/_layout/_pages/_protected/pages/-header.tsx index 15440573..53f19b46 100644 --- a/web/app/routes/_layout/_pages/_protected/pages/-header.tsx +++ b/web/app/routes/_layout/_pages/_protected/pages/-header.tsx @@ -24,7 +24,7 @@ export const PageHeader: React.FC = React.memo(() => { } return ( - +
diff --git a/web/app/routes/_layout/_pages/_protected/pages/-item.tsx b/web/app/routes/_layout/_pages/_protected/pages/-item.tsx index 2b90cae0..3761c413 100644 --- a/web/app/routes/_layout/_pages/_protected/pages/-item.tsx +++ b/web/app/routes/_layout/_pages/_protected/pages/-item.tsx @@ -36,8 +36,8 @@ export const PageItem = React.forwardRef( tabIndex={isActive ? 0 : -1} className={cn( "relative block cursor-default outline-none", - "min-h-12 py-2 max-lg:px-4 sm:px-6", - "data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", + "min-h-12 py-2 sm:px-6 max-lg:px-4", + "data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", )} to={`/pages/${page.id}`} aria-selected={isActive} @@ -47,7 +47,7 @@ export const PageItem = React.forwardRef( >
- + {page.title || "Untitled"} @@ -64,7 +64,7 @@ export const PageItem = React.forwardRef( style={columnStyles.updated} className="flex justify-end" > - + {format(new Date(page.updatedAt), "d MMM yyyy")} diff --git a/web/app/routes/_layout/_pages/_protected/pages/-list.tsx b/web/app/routes/_layout/_pages/_protected/pages/-list.tsx index 327ca53e..06252c14 100644 --- a/web/app/routes/_layout/_pages/_protected/pages/-list.tsx +++ b/web/app/routes/_layout/_pages/_protected/pages/-list.tsx @@ -54,7 +54,7 @@ export const PageList: React.FC = () => {
{!isTablet && } @@ -110,7 +110,7 @@ export const ColumnHeader: React.FC = () => { const columnStyles = useColumnStyles() return ( -
+
Title diff --git a/web/app/routes/_layout/_pages/_protected/profile/index.tsx b/web/app/routes/_layout/_pages/_protected/profile/index.tsx index 3f87d21f..b4e0fa13 100644 --- a/web/app/routes/_layout/_pages/_protected/profile/index.tsx +++ b/web/app/routes/_layout/_pages/_protected/profile/index.tsx @@ -137,7 +137,7 @@ function ProfileComponent() { {error && (

{error}

diff --git a/web/app/routes/_layout/_pages/_protected/search/index.tsx b/web/app/routes/_layout/_pages/_protected/search/index.tsx index 4c1f3d6e..dc2b774a 100644 --- a/web/app/routes/_layout/_pages/_protected/search/index.tsx +++ b/web/app/routes/_layout/_pages/_protected/search/index.tsx @@ -28,7 +28,7 @@ const SearchTitle: React.FC = ({ title, count }) => (

{title}

-
+
{count}
@@ -41,7 +41,7 @@ const SearchItem: React.FC = ({ subtitle, topic, }) => ( -
+
= ({ e.stopPropagation()} - className="hover:text-primary text-sm font-medium hover:opacity-70" + className="text-sm font-medium hover:text-primary hover:opacity-70" > {title} @@ -58,7 +58,7 @@ const SearchItem: React.FC = ({ e.stopPropagation()} - className="text-muted-foreground ml-2 truncate text-xs hover:underline" + className="ml-2 truncate text-xs text-muted-foreground hover:underline" > {subtitle} @@ -138,7 +138,7 @@ const SearchComponent = () => {
{ value={searchText} onChange={handleSearch} placeholder="Search topics, links, pages" - className="dark:bg-input w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600" + className="w-full rounded-lg border border-neutral-300 p-2 pl-8 focus:outline-none dark:border-neutral-600 dark:bg-input" /> {searchText && ( )} diff --git a/web/app/routes/_layout/_pages/_protected/tasks/-form.tsx b/web/app/routes/_layout/_pages/_protected/tasks/-form.tsx index 5eeae269..8aaa29e7 100644 --- a/web/app/routes/_layout/_pages/_protected/tasks/-form.tsx +++ b/web/app/routes/_layout/_pages/_protected/tasks/-form.tsx @@ -115,7 +115,7 @@ export const TaskForm: React.FC = () => {
= ({ : "No due date" return ( -
  • +
  • = ({

    = ({

    )}
    - {formattedDate} + {formattedDate}
  • ) } diff --git a/web/app/routes/_layout/_pages/_protected/topics/-header.tsx b/web/app/routes/_layout/_pages/_protected/topics/-header.tsx index f6fff225..e0162575 100644 --- a/web/app/routes/_layout/_pages/_protected/topics/-header.tsx +++ b/web/app/routes/_layout/_pages/_protected/topics/-header.tsx @@ -13,7 +13,7 @@ export const TopicHeader: React.FC = React.memo(() => { if (!me) return null return ( - +
    @@ -26,7 +26,9 @@ const HeaderTitle: React.FC = () => (
    - Topics + + Topics +
    ) diff --git a/web/app/routes/_layout/_pages/_protected/topics/-item.tsx b/web/app/routes/_layout/_pages/_protected/topics/-item.tsx index 902e5e94..37bcb07b 100644 --- a/web/app/routes/_layout/_pages/_protected/topics/-item.tsx +++ b/web/app/routes/_layout/_pages/_protected/topics/-item.tsx @@ -142,8 +142,8 @@ export const TopicItem = React.forwardRef( tabIndex={isActive ? 0 : -1} className={cn( "relative block cursor-default outline-none", - "min-h-12 py-2 max-lg:px-4 sm:px-6", - "data-[active='true']:bg-[var(--link-background-muted)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", + "min-h-12 py-2 sm:px-6 max-lg:px-4", + "data-[active='true']:bg-[var(--link-background-muted-new)] data-[keyboard-active='true']:focus-visible:shadow-[var(--link-shadow)_0px_0px_0px_1px_inset]", )} aria-selected={isActive} data-active={isActive} @@ -155,7 +155,7 @@ export const TopicItem = React.forwardRef( tabIndex={isActive ? 0 : -1} > - + {topic.prettyName} diff --git a/web/app/routes/_layout/_pages/_protected/topics/-list.tsx b/web/app/routes/_layout/_pages/_protected/topics/-list.tsx index 314e79a3..3833c2c3 100644 --- a/web/app/routes/_layout/_pages/_protected/topics/-list.tsx +++ b/web/app/routes/_layout/_pages/_protected/topics/-list.tsx @@ -94,10 +94,10 @@ export const MainTopicList: React.FC = ({ me }) => { }) return ( -
    +
    {!isTablet && } @@ -144,7 +144,7 @@ export const ColumnHeader: React.FC = () => { const columnStyles = useColumnStyles() return ( -
    +
    Name diff --git a/web/app/styles/app.css b/web/app/styles/app.css index 49dfb371..43c6242e 100644 --- a/web/app/styles/app.css +++ b/web/app/styles/app.css @@ -43,7 +43,7 @@ --foreground: 0 0% 98%; --card: 240 10% 3.9%; --card-foreground: 0 0% 98%; - --popover: 240 10% 3.9%; + --popover: 220, 5.66%, 10.39%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 240 5.9% 10%; @@ -55,7 +55,7 @@ --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; - --border: 240 3.7% 15.9%; + --border: 240 3.7% 20%; --input: 220 9% 10%; --result: 0 0% 7%; --ring: 240 4.9% 83.9%; @@ -77,7 +77,7 @@ } body, div#root { - @apply bg-background text-foreground size-full font-sans antialiased; + @apply size-full bg-[var(--body-background)] font-sans text-foreground antialiased; } } diff --git a/web/app/styles/command-palette.css b/web/app/styles/command-palette.css index dda807b1..40fb3cc5 100644 --- a/web/app/styles/command-palette.css +++ b/web/app/styles/command-palette.css @@ -116,11 +116,11 @@ .la [cmdk-group-heading] { font-size: 13px; height: 30px; - @apply text-muted-foreground flex items-center px-2; + @apply flex items-center px-2 text-muted-foreground; } .la [cmdk-empty] { - @apply text-muted-foreground flex h-16 items-center justify-center whitespace-pre-wrap text-sm; + @apply flex h-16 items-center justify-center whitespace-pre-wrap text-sm text-muted-foreground; } .la [cmdk-item] { diff --git a/web/app/styles/custom.css b/web/app/styles/custom.css index ebb69b13..efa9fcdd 100644 --- a/web/app/styles/custom.css +++ b/web/app/styles/custom.css @@ -1,11 +1,42 @@ :root { --link-background-muted: hsl(0, 0%, 97.3%); - --link-border-after: hsl(0, 0%, 91%); + --link-background-muted-new: hsl(0, 0%, 97.3%); + --la-border: hsl(0, 0%, 91%); + --la-border-new: hsl(0, 0%, 91%); --link-shadow: hsl(240, 5.6%, 82.5%); + --less-foreground: hsl(240 10% 3.9%); + + --item-active: rgb(228, 228, 229); + --item-hover: rgb(237, 237, 239); + --body-background: rgb(248, 248, 249); + --container-background: rgb(255, 255, 255); } .dark { --link-background-muted: hsl(220, 6.7%, 8.8%); - --link-border-after: hsl(230, 10%, 11.8%); + --link-background-muted-new: rgb(28, 29, 32); + --la-border: hsl(230, 10%, 11.8%); + --la-border-new: hsl(230, 10%, 15%); --link-shadow: hsl(234.9, 27.1%, 25.3%); + --less-foreground: #e5e7eb; + + --item-active: rgb(53, 54, 57); + --item-hover: rgb(42, 43, 46); + --body-background: rgb(31, 32, 35); + --container-background: rgb(24, 25, 28); +} + +.title-editor .ProseMirror .is-empty::before { + @apply pointer-events-none float-left h-0 w-full text-[var(--la-secondary)]; +} + +.title-editor:not(.no-command) + .ProseMirror.ProseMirror-focused + > p.has-focus.is-empty::before { + content: "Type / for commands..."; +} + +.title-editor .ProseMirror > p.is-editor-empty::before { + content: attr(data-placeholder); + @apply pointer-events-none float-left h-0 text-[var(--la-secondary)]; } diff --git a/web/package.json b/web/package.json index 1905d378..d23531d6 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,7 @@ }, "dependencies": { "@clerk/tanstack-start": "0.4.1", - "@clerk/themes": "^2.1.35", + "@clerk/themes": "^2.1.37", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -39,12 +39,12 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.3", - "@tanstack/react-query": "^5.59.0", - "@tanstack/react-router": "^1.62.0", - "@tanstack/react-router-with-query": "^1.62.0", + "@tanstack/react-query": "^5.59.15", "@tanstack/react-virtual": "^3.10.8", - "@tanstack/router-zod-adapter": "^1.62.0", - "@tanstack/start": "^1.62.0", + "@tanstack/react-router": "^1.70.1", + "@tanstack/react-router-with-query": "^1.70.1", + "@tanstack/router-zod-adapter": "^1.70.1", + "@tanstack/start": "^1.70.1", "@tiptap/core": "^2.8.0", "@tiptap/extension-code-block-lowlight": "^2.8.0", "@tiptap/extension-color": "^2.8.0", @@ -67,50 +67,59 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^4.1.0", - "dotenv": "^16.4.5", - "framer-motion": "^11.11.1", - "jazz-react": "^0.8.2", - "jazz-react-auth-clerk": "^0.8.2", - "jazz-tools": "^0.8.2", - "jotai": "^2.10.0", + "framer-motion": "^11.11.9", + "jazz-browser-media-images": "^0.8.7", + "jazz-react": "^0.8.7", + "jazz-react-auth-clerk": "^0.8.7", + "jazz-tools": "^0.8.5", + "jotai": "^2.10.1", "lowlight": "^3.1.0", "lucide-react": "^0.446.0", "next-themes": "^0.3.0", - "query-string": "^9.1.0", + "query-string": "^9.1.1", "react": "^18.3.1", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", - "react-textarea-autosize": "^8.5.3", + "react-medium-image-zoom": "^5.2.10", + "react-textarea-autosize": "^8.5.4", "ronin": "^4.3.1", "slugify": "^1.6.6", "sonner": "^1.5.0", "streaming-markdown": "^0.0.14", - "tailwind-merge": "^2.5.3", + "tailwind-merge": "^2.5.4", "tailwindcss-animate": "^1.0.7", + "uuid": "^10.0.0", "vinxi": "0.4.3", "zod": "^3.23.8" }, "devDependencies": { - "@ronin/learn-anything": "^0.0.0-3456082797916", + "@ronin/learn-anything": "^0.0.0-3457754034220", "@tailwindcss/typography": "^0.5.15", - "@tanstack/react-query-devtools": "^5.59.0", - "@tanstack/router-devtools": "^1.62.0", - "@types/node": "^22.7.4", + "@tanstack/react-query-devtools": "^5.59.15", + "@tanstack/router-devtools": "^1.70.1", + "@types/node": "^22.7.6", "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.0", + "@types/react-dom": "^18.3.1", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", + "dotenv": "^16.4.5", "eslint": "^8.57.1", "eslint-plugin-react-hooks": "^4.6.2", "postcss": "^8.4.47", - "tailwindcss": "^3.4.13", - "typescript": "^5.6.2", - "vite-tsconfig-paths": "^5.0.1" + "tailwindcss": "^3.4.14", + "typescript": "^5.6.3", + "vite-tsconfig-paths": "^5.0.1", + "prettier": "^3.3.3", + "prettier-plugin-tailwindcss": "^0.5.14" }, "prettier": { + "plugins": [ + "prettier-plugin-tailwindcss" + ], "semi": false } } diff --git a/web/shared/components/spinner.tsx b/web/shared/components/spinner.tsx new file mode 100644 index 00000000..bc36e229 --- /dev/null +++ b/web/shared/components/spinner.tsx @@ -0,0 +1,37 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface SpinnerProps extends React.SVGProps {} + +const SpinnerComponent = React.forwardRef( + function Spinner({ className, ...props }, ref) { + return ( + + + + + ) + }, +) + +SpinnerComponent.displayName = "Spinner" + +export const Spinner = React.memo(SpinnerComponent) diff --git a/web/shared/editor/components/bubble-menu/bubble-menu.tsx b/web/shared/editor/components/bubble-menu/bubble-menu.tsx new file mode 100644 index 00000000..aa78e04d --- /dev/null +++ b/web/shared/editor/components/bubble-menu/bubble-menu.tsx @@ -0,0 +1,90 @@ +import { useTextmenuCommands } from "../../hooks/use-text-menu-commands" +import { PopoverWrapper } from "../ui/popover-wrapper" +import { useTextmenuStates } from "../../hooks/use-text-menu-states" +import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react" +import { ToolbarButton } from "../ui/toolbar-button" +import { Icon } from "../ui/icon" + +export type BubbleMenuProps = { + editor: Editor +} + +export const BubbleMenu = ({ editor }: BubbleMenuProps) => { + const commands = useTextmenuCommands(editor) + const states = useTextmenuStates(editor) + + return ( + + +
    + + + + + + + + + + + + + + + + + + + + + + + + {/* + + */} +
    +
    +
    + ) +} + +export default BubbleMenu diff --git a/web/shared/la-editor/components/bubble-menu/index.ts b/web/shared/editor/components/bubble-menu/index.ts similarity index 100% rename from web/shared/la-editor/components/bubble-menu/index.ts rename to web/shared/editor/components/bubble-menu/index.ts diff --git a/web/shared/la-editor/components/ui/icon.tsx b/web/shared/editor/components/ui/icon.tsx similarity index 100% rename from web/shared/la-editor/components/ui/icon.tsx rename to web/shared/editor/components/ui/icon.tsx diff --git a/web/shared/la-editor/components/ui/popover-wrapper.tsx b/web/shared/editor/components/ui/popover-wrapper.tsx similarity index 87% rename from web/shared/la-editor/components/ui/popover-wrapper.tsx rename to web/shared/editor/components/ui/popover-wrapper.tsx index a0c6adfc..5cd94d95 100644 --- a/web/shared/la-editor/components/ui/popover-wrapper.tsx +++ b/web/shared/editor/components/ui/popover-wrapper.tsx @@ -10,7 +10,7 @@ export const PopoverWrapper = React.forwardRef< return (
    { diff --git a/web/shared/la-editor/components/ui/toolbar-button.tsx b/web/shared/editor/components/ui/toolbar-button.tsx similarity index 100% rename from web/shared/la-editor/components/ui/toolbar-button.tsx rename to web/shared/editor/components/ui/toolbar-button.tsx diff --git a/web/shared/editor/editor.tsx b/web/shared/editor/editor.tsx new file mode 100644 index 00000000..1fe79ca8 --- /dev/null +++ b/web/shared/editor/editor.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import "./styles/index.css" + +import { EditorContent } from "@tiptap/react" +import { Content } from "@tiptap/core" +import { BubbleMenu } from "./components/bubble-menu" +import { cn } from "@/lib/utils" +import { useLaEditor, UseLaEditorProps } from "./hooks/use-la-editor" + +export interface LaEditorProps extends UseLaEditorProps { + value?: Content + className?: string + editorContentClassName?: string +} + +export const LaEditor = React.memo( + React.forwardRef( + ({ className, editorContentClassName, ...props }, ref) => { + const editor = useLaEditor(props) + + if (!editor) { + return null + } + + return ( +
    + + +
    + ) + }, + ), +) + +LaEditor.displayName = "LaEditor" + +export default LaEditor diff --git a/web/shared/la-editor/extensions/blockquote/blockquote.ts b/web/shared/editor/extensions/blockquote/blockquote.ts similarity index 100% rename from web/shared/la-editor/extensions/blockquote/blockquote.ts rename to web/shared/editor/extensions/blockquote/blockquote.ts diff --git a/web/shared/la-editor/extensions/blockquote/index.ts b/web/shared/editor/extensions/blockquote/index.ts similarity index 100% rename from web/shared/la-editor/extensions/blockquote/index.ts rename to web/shared/editor/extensions/blockquote/index.ts diff --git a/web/shared/la-editor/extensions/bullet-list/bullet-list.ts b/web/shared/editor/extensions/bullet-list/bullet-list.ts similarity index 100% rename from web/shared/la-editor/extensions/bullet-list/bullet-list.ts rename to web/shared/editor/extensions/bullet-list/bullet-list.ts diff --git a/web/shared/la-editor/extensions/bullet-list/index.ts b/web/shared/editor/extensions/bullet-list/index.ts similarity index 100% rename from web/shared/la-editor/extensions/bullet-list/index.ts rename to web/shared/editor/extensions/bullet-list/index.ts diff --git a/web/shared/la-editor/extensions/code-block-lowlight/code-block-lowlight.ts b/web/shared/editor/extensions/code-block-lowlight/code-block-lowlight.ts similarity index 100% rename from web/shared/la-editor/extensions/code-block-lowlight/code-block-lowlight.ts rename to web/shared/editor/extensions/code-block-lowlight/code-block-lowlight.ts diff --git a/web/shared/la-editor/extensions/code-block-lowlight/index.ts b/web/shared/editor/extensions/code-block-lowlight/index.ts similarity index 100% rename from web/shared/la-editor/extensions/code-block-lowlight/index.ts rename to web/shared/editor/extensions/code-block-lowlight/index.ts diff --git a/web/shared/la-editor/extensions/code/code.ts b/web/shared/editor/extensions/code/code.ts similarity index 100% rename from web/shared/la-editor/extensions/code/code.ts rename to web/shared/editor/extensions/code/code.ts diff --git a/web/shared/la-editor/extensions/code/index.ts b/web/shared/editor/extensions/code/index.ts similarity index 100% rename from web/shared/la-editor/extensions/code/index.ts rename to web/shared/editor/extensions/code/index.ts diff --git a/web/shared/la-editor/extensions/dropcursor/dropcursor.ts b/web/shared/editor/extensions/dropcursor/dropcursor.ts similarity index 100% rename from web/shared/la-editor/extensions/dropcursor/dropcursor.ts rename to web/shared/editor/extensions/dropcursor/dropcursor.ts diff --git a/web/shared/la-editor/extensions/dropcursor/index.ts b/web/shared/editor/extensions/dropcursor/index.ts similarity index 100% rename from web/shared/la-editor/extensions/dropcursor/index.ts rename to web/shared/editor/extensions/dropcursor/index.ts diff --git a/web/shared/editor/extensions/file-handler/index.ts b/web/shared/editor/extensions/file-handler/index.ts new file mode 100644 index 00000000..be7c35c5 --- /dev/null +++ b/web/shared/editor/extensions/file-handler/index.ts @@ -0,0 +1,116 @@ +import { type Editor, Extension } from "@tiptap/core" +import { Plugin, PluginKey } from "@tiptap/pm/state" +import type { FileError, FileValidationOptions } from "@shared/editor/lib/utils" +import { filterFiles } from "@shared/editor/lib/utils" + +type FileHandlePluginOptions = { + key?: PluginKey + editor: Editor + onPaste?: (editor: Editor, files: File[], pasteContent?: string) => void + onDrop?: (editor: Editor, files: File[], pos: number) => void + onValidationError?: (errors: FileError[]) => void +} & FileValidationOptions + +const FileHandlePlugin = (options: FileHandlePluginOptions) => { + const { + key, + editor, + onPaste, + onDrop, + onValidationError, + allowedMimeTypes, + maxFileSize, + } = options + + return new Plugin({ + key: key || new PluginKey("fileHandler"), + + props: { + handleDrop(view, event) { + event.preventDefault() + event.stopPropagation() + + const { dataTransfer } = event + + if (!dataTransfer?.files.length) { + return + } + + const pos = view.posAtCoords({ + left: event.clientX, + top: event.clientY, + }) + + const [validFiles, errors] = filterFiles( + Array.from(dataTransfer.files), + { + allowedMimeTypes, + maxFileSize, + allowBase64: options.allowBase64, + }, + ) + + if (errors.length > 0 && onValidationError) { + onValidationError(errors) + } + + if (validFiles.length > 0 && onDrop) { + onDrop(editor, validFiles, pos?.pos ?? 0) + } + }, + + handlePaste(_, event) { + event.preventDefault() + event.stopPropagation() + + const { clipboardData } = event + + if (!clipboardData?.files.length) { + return + } + + const [validFiles, errors] = filterFiles( + Array.from(clipboardData.files), + { + allowedMimeTypes, + maxFileSize, + allowBase64: options.allowBase64, + }, + ) + const html = clipboardData.getData("text/html") + + if (errors.length > 0 && onValidationError) { + onValidationError(errors) + } + + if (validFiles.length > 0 && onPaste) { + onPaste(editor, validFiles, html) + } + }, + }, + }) +} + +export const FileHandler = Extension.create< + Omit +>({ + name: "fileHandler", + + addOptions() { + return { + allowBase64: false, + allowedMimeTypes: [], + maxFileSize: 0, + } + }, + + addProseMirrorPlugins() { + return [ + FileHandlePlugin({ + key: new PluginKey(this.name), + editor: this.editor, + ...this.options, + }), + ] + }, +}) diff --git a/web/shared/la-editor/extensions/heading/heading.ts b/web/shared/editor/extensions/heading/heading.ts similarity index 100% rename from web/shared/la-editor/extensions/heading/heading.ts rename to web/shared/editor/extensions/heading/heading.ts diff --git a/web/shared/la-editor/extensions/heading/index.ts b/web/shared/editor/extensions/heading/index.ts similarity index 100% rename from web/shared/la-editor/extensions/heading/index.ts rename to web/shared/editor/extensions/heading/index.ts diff --git a/web/shared/la-editor/extensions/horizontal-rule/horizontal-rule.ts b/web/shared/editor/extensions/horizontal-rule/horizontal-rule.ts similarity index 100% rename from web/shared/la-editor/extensions/horizontal-rule/horizontal-rule.ts rename to web/shared/editor/extensions/horizontal-rule/horizontal-rule.ts diff --git a/web/shared/la-editor/extensions/horizontal-rule/index.ts b/web/shared/editor/extensions/horizontal-rule/index.ts similarity index 100% rename from web/shared/la-editor/extensions/horizontal-rule/index.ts rename to web/shared/editor/extensions/horizontal-rule/index.ts diff --git a/web/shared/editor/extensions/image/components/image-actions.tsx b/web/shared/editor/extensions/image/components/image-actions.tsx new file mode 100644 index 00000000..86383841 --- /dev/null +++ b/web/shared/editor/extensions/image/components/image-actions.tsx @@ -0,0 +1,173 @@ +import * as React from "react" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + ClipboardCopyIcon, + DotsHorizontalIcon, + DownloadIcon, + Link2Icon, + SizeIcon, +} from "@radix-ui/react-icons" + +interface ImageActionsProps { + shouldMerge?: boolean + isLink?: boolean + onView?: () => void + onDownload?: () => void + onCopy?: () => void + onCopyLink?: () => void +} + +interface ActionButtonProps + extends React.ButtonHTMLAttributes { + icon: React.ReactNode + tooltip: string +} + +export const ActionWrapper = React.memo( + React.forwardRef>( + ({ children, className, ...props }, ref) => ( +
    + {children} +
    + ), + ), +) + +ActionWrapper.displayName = "ActionWrapper" + +export const ActionButton = React.memo( + React.forwardRef( + ({ icon, tooltip, className, ...props }, ref) => ( + + + + + {tooltip} + + ), + ), +) + +ActionButton.displayName = "ActionButton" + +type ActionKey = "onView" | "onDownload" | "onCopy" | "onCopyLink" + +const ActionItems: Array<{ + key: ActionKey + icon: React.ReactNode + tooltip: string + isLink?: boolean +}> = [ + { + key: "onView", + icon: , + tooltip: "View image", + }, + { + key: "onDownload", + icon: , + tooltip: "Download image", + }, + { + key: "onCopy", + icon: , + tooltip: "Copy image to clipboard", + }, + { + key: "onCopyLink", + icon: , + tooltip: "Copy image link", + isLink: true, + }, +] + +export const ImageActions: React.FC = React.memo( + ({ shouldMerge = false, isLink = false, ...actions }) => { + const [isOpen, setIsOpen] = React.useState(false) + + const handleAction = React.useCallback( + (e: React.MouseEvent, action: (() => void) | undefined) => { + e.preventDefault() + e.stopPropagation() + action?.() + }, + [], + ) + + const filteredActions = React.useMemo( + () => ActionItems.filter((item) => isLink || !item.isLink), + [isLink], + ) + + return ( + + {shouldMerge ? ( + + + } + tooltip="Open menu" + onClick={(e) => e.preventDefault()} + /> + + + {filteredActions.map(({ key, icon, tooltip }) => ( + handleAction(e, actions[key])} + > +
    + {icon} + {tooltip} +
    +
    + ))} +
    +
    + ) : ( + filteredActions.map(({ key, icon, tooltip }) => ( + handleAction(e, actions[key])} + /> + )) + )} +
    + ) + }, +) + +ImageActions.displayName = "ImageActions" diff --git a/web/shared/editor/extensions/image/components/image-overlay.tsx b/web/shared/editor/extensions/image/components/image-overlay.tsx new file mode 100644 index 00000000..432c37b3 --- /dev/null +++ b/web/shared/editor/extensions/image/components/image-overlay.tsx @@ -0,0 +1,16 @@ +import * as React from "react" +import { cn } from "@/lib/utils" +import { Spinner } from "@shared/components/spinner" + +export const ImageOverlay = React.memo(() => { + return ( +
    + +
    + ) +}) diff --git a/web/shared/editor/extensions/image/components/image-view-block.tsx b/web/shared/editor/extensions/image/components/image-view-block.tsx new file mode 100644 index 00000000..a66acee7 --- /dev/null +++ b/web/shared/editor/extensions/image/components/image-view-block.tsx @@ -0,0 +1,311 @@ +import * as React from "react" +import { NodeViewWrapper, type NodeViewProps } from "@tiptap/react" +import type { ElementDimensions } from "../hooks/use-drag-resize" +import { useDragResize } from "../hooks/use-drag-resize" +import { ResizeHandle } from "./resize-handle" +import { cn } from "@/lib/utils" +import { NodeSelection } from "@tiptap/pm/state" +import { Controlled as ControlledZoom } from "react-medium-image-zoom" +import { ActionButton, ActionWrapper, ImageActions } from "./image-actions" +import { useImageActions } from "../hooks/use-image-actions" +import { blobUrlToBase64 } from "@shared/editor/lib/utils" +import { InfoCircledIcon, TrashIcon } from "@radix-ui/react-icons" +import { ImageOverlay } from "./image-overlay" +import { Spinner } from "@shared/components/spinner" + +const MAX_HEIGHT = 600 +const MIN_HEIGHT = 120 +const MIN_WIDTH = 120 + +interface ImageState { + src: string + isServerUploading: boolean + imageLoaded: boolean + isZoomed: boolean + error: boolean + naturalSize: ElementDimensions +} + +export const ImageViewBlock: React.FC = ({ + editor, + node, + getPos, + selected, + updateAttributes, +}) => { + const { + src: initialSrc, + width: initialWidth, + height: initialHeight, + } = node.attrs + const [imageState, setImageState] = React.useState({ + src: initialSrc, + isServerUploading: false, + imageLoaded: false, + isZoomed: false, + error: false, + naturalSize: { width: initialWidth, height: initialHeight }, + }) + + const containerRef = React.useRef(null) + const [activeResizeHandle, setActiveResizeHandle] = React.useState< + "left" | "right" | null + >(null) + + const focus = React.useCallback(() => { + const { view } = editor + const $pos = view.state.doc.resolve(getPos()) + view.dispatch(view.state.tr.setSelection(new NodeSelection($pos))) + }, [editor, getPos]) + + const onDimensionsChange = React.useCallback( + ({ width, height }: ElementDimensions) => { + focus() + updateAttributes({ width, height }) + }, + [focus, updateAttributes], + ) + + const aspectRatio = + imageState.naturalSize.width / imageState.naturalSize.height + const maxWidth = MAX_HEIGHT * aspectRatio + + const { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg } = + useImageActions({ + editor, + node, + src: imageState.src, + onViewClick: (isZoomed) => + setImageState((prev) => ({ ...prev, isZoomed })), + }) + + const { + currentWidth, + currentHeight, + updateDimensions, + initiateResize, + isResizing, + } = useDragResize({ + initialWidth: initialWidth ?? imageState.naturalSize.width, + initialHeight: initialHeight ?? imageState.naturalSize.height, + contentWidth: imageState.naturalSize.width, + contentHeight: imageState.naturalSize.height, + gridInterval: 0.1, + onDimensionsChange, + minWidth: MIN_WIDTH, + minHeight: MIN_HEIGHT, + maxWidth, + }) + + const shouldMerge = React.useMemo(() => currentWidth <= 180, [currentWidth]) + + const handleImageLoad = React.useCallback( + (ev: React.SyntheticEvent) => { + const img = ev.target as HTMLImageElement + const newNaturalSize = { + width: img.naturalWidth, + height: img.naturalHeight, + } + setImageState((prev) => ({ + ...prev, + naturalSize: newNaturalSize, + imageLoaded: true, + })) + updateAttributes({ + width: img.width || newNaturalSize.width, + height: img.height || newNaturalSize.height, + alt: img.alt, + title: img.title, + }) + + if (!initialWidth) { + updateDimensions((state) => ({ ...state, width: newNaturalSize.width })) + } + }, + [initialWidth, updateAttributes, updateDimensions], + ) + + const handleImageError = React.useCallback(() => { + setImageState((prev) => ({ ...prev, error: true, imageLoaded: true })) + }, []) + + const handleResizeStart = React.useCallback( + (direction: "left" | "right") => + (event: React.PointerEvent) => { + setActiveResizeHandle(direction) + initiateResize(direction)(event) + }, + [initiateResize], + ) + + const handleResizeEnd = React.useCallback(() => { + setActiveResizeHandle(null) + }, []) + + React.useEffect(() => { + if (!isResizing) { + handleResizeEnd() + } + }, [isResizing, handleResizeEnd]) + + React.useEffect(() => { + const handleImage = async () => { + const imageExtension = editor.options.extensions.find( + (ext) => ext.name === "image", + ) + const { uploadFn } = imageExtension?.options ?? {} + + if (initialSrc.startsWith("blob:")) { + if (!uploadFn) { + try { + const base64 = await blobUrlToBase64(initialSrc) + setImageState((prev) => ({ ...prev, src: base64 })) + updateAttributes({ src: base64 }) + } catch { + setImageState((prev) => ({ ...prev, error: true })) + } + } else { + try { + setImageState((prev) => ({ ...prev, isServerUploading: true })) + const url = await uploadFn(initialSrc, editor) + setImageState((prev) => ({ + ...prev, + src: url, + isServerUploading: false, + })) + updateAttributes({ src: url }) + } catch { + setImageState((prev) => ({ + ...prev, + error: true, + isServerUploading: false, + })) + } + } + } + } + + handleImage() + }, [editor, initialSrc, updateAttributes]) + + return ( + +
    +
    +
    +
    + {!imageState.imageLoaded && !imageState.error && ( +
    + +
    + )} + + {imageState.error && ( +
    + +

    + Failed to load image +

    +
    + )} + + + setImageState((prev) => ({ ...prev, isZoomed: false })) + } + > + {node.attrs.alt + +
    + + {imageState.isServerUploading && } + + {editor.isEditable && + imageState.imageLoaded && + !imageState.error && + !imageState.isServerUploading && ( + <> + + + + )} +
    + + {imageState.error && ( + + } + tooltip="Remove image" + onClick={onRemoveImg} + /> + + )} + + {!isResizing && + !imageState.error && + !imageState.isServerUploading && ( + + )} +
    +
    +
    + ) +} diff --git a/web/shared/editor/extensions/image/components/resize-handle.tsx b/web/shared/editor/extensions/image/components/resize-handle.tsx new file mode 100644 index 00000000..c09a4f09 --- /dev/null +++ b/web/shared/editor/extensions/image/components/resize-handle.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import { cn } from "@/lib/utils" + +interface ResizeProps extends React.HTMLAttributes { + isResizing?: boolean +} + +export const ResizeHandle = React.forwardRef( + ({ className, isResizing = false, ...props }, ref) => { + return ( +
    + ) + }, +) + +ResizeHandle.displayName = "ResizeHandle" diff --git a/web/shared/editor/extensions/image/hooks/use-drag-resize.ts b/web/shared/editor/extensions/image/hooks/use-drag-resize.ts new file mode 100644 index 00000000..9228282f --- /dev/null +++ b/web/shared/editor/extensions/image/hooks/use-drag-resize.ts @@ -0,0 +1,171 @@ +import { useState, useCallback, useEffect } from "react" + +type ResizeDirection = "left" | "right" +export type ElementDimensions = { width: number; height: number } + +type HookParams = { + initialWidth?: number + initialHeight?: number + contentWidth?: number + contentHeight?: number + gridInterval: number + minWidth: number + minHeight: number + maxWidth: number + onDimensionsChange?: (dimensions: ElementDimensions) => void +} + +export function useDragResize({ + initialWidth, + initialHeight, + contentWidth, + contentHeight, + gridInterval, + minWidth, + minHeight, + maxWidth, + onDimensionsChange, +}: HookParams) { + const [dimensions, updateDimensions] = useState({ + width: Math.max(initialWidth ?? minWidth, minWidth), + height: Math.max(initialHeight ?? minHeight, minHeight), + }) + const [boundaryWidth, setBoundaryWidth] = useState(Infinity) + const [resizeOrigin, setResizeOrigin] = useState(0) + const [initialDimensions, setInitialDimensions] = useState(dimensions) + const [resizeDirection, setResizeDirection] = useState< + ResizeDirection | undefined + >() + + const widthConstraint = useCallback( + (proposedWidth: number, maxAllowedWidth: number) => { + const effectiveMinWidth = Math.max( + minWidth, + Math.min( + contentWidth ?? minWidth, + (gridInterval / 100) * maxAllowedWidth, + ), + ) + return Math.min( + maxAllowedWidth, + Math.max(proposedWidth, effectiveMinWidth), + ) + }, + [gridInterval, contentWidth, minWidth], + ) + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + event.preventDefault() + const movementDelta = + (resizeDirection === "left" + ? resizeOrigin - event.pageX + : event.pageX - resizeOrigin) * 2 + const gridUnitWidth = (gridInterval / 100) * boundaryWidth + const proposedWidth = initialDimensions.width + movementDelta + const alignedWidth = + Math.round(proposedWidth / gridUnitWidth) * gridUnitWidth + const finalWidth = widthConstraint(alignedWidth, boundaryWidth) + const aspectRatio = + contentHeight && contentWidth ? contentHeight / contentWidth : 1 + + updateDimensions({ + width: Math.max(finalWidth, minWidth), + height: Math.max( + contentWidth + ? finalWidth * aspectRatio + : (contentHeight ?? minHeight), + minHeight, + ), + }) + }, + [ + widthConstraint, + resizeDirection, + boundaryWidth, + resizeOrigin, + gridInterval, + contentHeight, + contentWidth, + initialDimensions.width, + minWidth, + minHeight, + ], + ) + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + event.preventDefault() + event.stopPropagation() + + setResizeOrigin(0) + setResizeDirection(undefined) + onDimensionsChange?.(dimensions) + }, + [onDimensionsChange, dimensions], + ) + + const handleKeydown = useCallback( + (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + event.stopPropagation() + updateDimensions({ + width: Math.max(initialDimensions.width, minWidth), + height: Math.max(initialDimensions.height, minHeight), + }) + setResizeDirection(undefined) + } + }, + [initialDimensions, minWidth, minHeight], + ) + + const initiateResize = useCallback( + (direction: ResizeDirection) => + (event: React.PointerEvent) => { + event.preventDefault() + event.stopPropagation() + + setBoundaryWidth(maxWidth) + setInitialDimensions({ + width: Math.max( + widthConstraint(dimensions.width, maxWidth), + minWidth, + ), + height: Math.max(dimensions.height, minHeight), + }) + setResizeOrigin(event.pageX) + setResizeDirection(direction) + }, + [ + maxWidth, + widthConstraint, + dimensions.width, + dimensions.height, + minWidth, + minHeight, + ], + ) + + useEffect(() => { + if (resizeDirection) { + document.addEventListener("keydown", handleKeydown) + document.addEventListener("pointermove", handlePointerMove) + document.addEventListener("pointerup", handlePointerUp) + + return () => { + document.removeEventListener("keydown", handleKeydown) + document.removeEventListener("pointermove", handlePointerMove) + document.removeEventListener("pointerup", handlePointerUp) + } + } + }, [resizeDirection, handleKeydown, handlePointerMove, handlePointerUp]) + + return { + initiateResize, + isResizing: !!resizeDirection, + updateDimensions, + currentWidth: Math.max(dimensions.width, minWidth), + currentHeight: Math.max(dimensions.height, minHeight), + } +} diff --git a/web/shared/editor/extensions/image/hooks/use-image-actions.ts b/web/shared/editor/extensions/image/hooks/use-image-actions.ts new file mode 100644 index 00000000..98b93e4a --- /dev/null +++ b/web/shared/editor/extensions/image/hooks/use-image-actions.ts @@ -0,0 +1,61 @@ +import * as React from "react" +import type { Editor } from "@tiptap/core" +import type { Node } from "@tiptap/pm/model" +import { isUrl } from "@shared/editor/lib/utils" + +interface UseImageActionsProps { + editor: Editor + node: Node + src: string + onViewClick: (value: boolean) => void +} + +export type ImageActionHandlers = { + onView?: () => void + onDownload?: () => void + onCopy?: () => void + onCopyLink?: () => void + onRemoveImg?: () => void +} + +export const useImageActions = ({ + editor, + node, + src, + onViewClick, +}: UseImageActionsProps) => { + const isLink = React.useMemo(() => isUrl(src), [src]) + + const onView = React.useCallback(() => { + onViewClick(true) + }, [onViewClick]) + + const onDownload = React.useCallback(() => { + editor.commands.downloadImage({ src: node.attrs.src, alt: node.attrs.alt }) + }, [editor.commands, node.attrs.alt, node.attrs.src]) + + const onCopy = React.useCallback(() => { + editor.commands.copyImage({ src: node.attrs.src }) + }, [editor.commands, node.attrs.src]) + + const onCopyLink = React.useCallback(() => { + editor.commands.copyLink({ src: node.attrs.src }) + }, [editor.commands, node.attrs.src]) + + const onRemoveImg = React.useCallback(() => { + editor.commands.command(({ tr, dispatch }) => { + const { selection } = tr + const nodeAtSelection = tr.doc.nodeAt(selection.from) + + if (nodeAtSelection && nodeAtSelection.type.name === "image") { + if (dispatch) { + tr.deleteSelection() + return true + } + } + return false + }) + }, [editor.commands]) + + return { isLink, onView, onDownload, onCopy, onCopyLink, onRemoveImg } +} diff --git a/web/shared/editor/extensions/image/image.ts b/web/shared/editor/extensions/image/image.ts new file mode 100644 index 00000000..6c323d0d --- /dev/null +++ b/web/shared/editor/extensions/image/image.ts @@ -0,0 +1,288 @@ +import type { ImageOptions } from "@tiptap/extension-image" +import { Image as TiptapImage } from "@tiptap/extension-image" +import type { Editor } from "@tiptap/react" +import { ReactNodeViewRenderer } from "@tiptap/react" +import { ImageViewBlock } from "./components/image-view-block" +import { + filterFiles, + sanitizeUrl, + type FileError, + type FileValidationOptions, +} from "@shared/editor/lib/utils" + +interface DownloadImageCommandProps { + src: string + alt?: string +} + +interface ImageActionProps { + src: string + alt?: string + action: "download" | "copyImage" | "copyLink" +} + +interface CustomImageOptions + extends ImageOptions, + Omit { + uploadFn?: (blobUrl: string, editor: Editor) => Promise + onToggle?: (editor: Editor, files: File[], pos: number) => void + onActionSuccess?: (props: ImageActionProps) => void + onActionError?: (error: Error, props: ImageActionProps) => void + customDownloadImage?: ( + props: ImageActionProps, + options: CustomImageOptions, + ) => void + customCopyImage?: ( + props: ImageActionProps, + options: CustomImageOptions, + ) => void + customCopyLink?: ( + props: ImageActionProps, + options: CustomImageOptions, + ) => void + onValidationError?: (errors: FileError[]) => void +} + +declare module "@tiptap/core" { + interface Commands { + toggleImage: { + toggleImage: () => ReturnType + } + setImages: { + setImages: ( + attrs: { src: string | File; alt?: string; title?: string }[], + ) => ReturnType + } + downloadImage: { + downloadImage: (attrs: DownloadImageCommandProps) => ReturnType + } + copyImage: { + copyImage: (attrs: DownloadImageCommandProps) => ReturnType + } + copyLink: { + copyLink: (attrs: DownloadImageCommandProps) => ReturnType + } + } +} + +const handleError = ( + error: unknown, + props: ImageActionProps, + errorHandler?: (error: Error, props: ImageActionProps) => void, +) => { + const typedError = error instanceof Error ? error : new Error("Unknown error") + errorHandler?.(typedError, props) +} + +const handleDataUrl = (src: string): { blob: Blob; extension: string } => { + const [header, base64Data] = src.split(",") + const mimeType = header.split(":")[1].split(";")[0] + const extension = mimeType.split("/")[1] + const byteCharacters = atob(base64Data) + const byteArray = new Uint8Array(byteCharacters.length) + for (let i = 0; i < byteCharacters.length; i++) { + byteArray[i] = byteCharacters.charCodeAt(i) + } + const blob = new Blob([byteArray], { type: mimeType }) + return { blob, extension } +} + +const handleImageUrl = async ( + src: string, +): Promise<{ blob: Blob; extension: string }> => { + const response = await fetch(src) + if (!response.ok) throw new Error("Failed to fetch image") + const blob = await response.blob() + const extension = blob.type.split(/\/|\+/)[1] + return { blob, extension } +} + +const fetchImageBlob = async ( + src: string, +): Promise<{ blob: Blob; extension: string }> => { + if (src.startsWith("data:")) { + return handleDataUrl(src) + } else { + return handleImageUrl(src) + } +} + +const saveImage = async ( + blob: Blob, + name: string, + extension: string, +): Promise => { + const imageURL = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = imageURL + link.download = `${name}.${extension}` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(imageURL) +} + +const defaultDownloadImage = async ( + props: ImageActionProps, + options: CustomImageOptions, +): Promise => { + const { src, alt } = props + const potentialName = alt || "image" + + try { + const { blob, extension } = await fetchImageBlob(src) + await saveImage(blob, potentialName, extension) + options.onActionSuccess?.({ ...props, action: "download" }) + } catch (error) { + handleError(error, { ...props, action: "download" }, options.onActionError) + } +} + +const defaultCopyImage = async ( + props: ImageActionProps, + options: CustomImageOptions, +) => { + const { src } = props + try { + const res = await fetch(src) + const blob = await res.blob() + await navigator.clipboard.write([new ClipboardItem({ [blob.type]: blob })]) + options.onActionSuccess?.({ ...props, action: "copyImage" }) + } catch (error) { + handleError(error, { ...props, action: "copyImage" }, options.onActionError) + } +} + +const defaultCopyLink = async ( + props: ImageActionProps, + options: CustomImageOptions, +) => { + const { src } = props + try { + await navigator.clipboard.writeText(src) + options.onActionSuccess?.({ ...props, action: "copyLink" }) + } catch (error) { + handleError(error, { ...props, action: "copyLink" }, options.onActionError) + } +} + +export const Image = TiptapImage.extend({ + atom: true, + + addOptions() { + return { + ...this.parent?.(), + allowedMimeTypes: [], + maxFileSize: 0, + uploadFn: undefined, + onToggle: undefined, + } + }, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: undefined, + }, + height: { + default: undefined, + }, + } + }, + + addCommands() { + return { + toggleImage: () => (props) => { + const input = document.createElement("input") + input.type = "file" + input.accept = this.options.allowedMimeTypes.join(",") + input.onchange = () => { + const files = input.files + if (!files) return + + const [validImages, errors] = filterFiles(Array.from(files), { + allowedMimeTypes: this.options.allowedMimeTypes, + maxFileSize: this.options.maxFileSize, + allowBase64: this.options.allowBase64, + }) + + if (errors.length > 0 && this.options.onValidationError) { + this.options.onValidationError(errors) + return false + } + + if (validImages.length === 0) return false + + if (this.options.onToggle) { + this.options.onToggle( + props.editor, + validImages, + props.editor.state.selection.from, + ) + } + } + + input.click() + return true + }, + setImages: + (attrs) => + ({ commands }) => { + const [validImages, errors] = filterFiles(attrs, { + allowedMimeTypes: this.options.allowedMimeTypes, + maxFileSize: this.options.maxFileSize, + allowBase64: this.options.allowBase64, + }) + + if (errors.length > 0 && this.options.onValidationError) { + this.options.onValidationError(errors) + } + + if (validImages.length > 0) { + return commands.insertContent( + validImages.map((image) => { + return { + type: this.name, + attrs: { + src: + image.src instanceof File + ? sanitizeUrl(URL.createObjectURL(image.src), { + allowBase64: this.options.allowBase64, + }) + : image.src, + alt: image.alt, + title: image.title, + }, + } + }), + ) + } + + return false + }, + downloadImage: (attrs) => () => { + const downloadFunc = + this.options.customDownloadImage || defaultDownloadImage + void downloadFunc({ ...attrs, action: "download" }, this.options) + return true + }, + copyImage: (attrs) => () => { + const copyImageFunc = this.options.customCopyImage || defaultCopyImage + void copyImageFunc({ ...attrs, action: "copyImage" }, this.options) + return true + }, + copyLink: (attrs) => () => { + const copyLinkFunc = this.options.customCopyLink || defaultCopyLink + void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options) + return true + }, + } + }, + + addNodeView() { + return ReactNodeViewRenderer(ImageViewBlock, { + className: "block-node", + }) + }, +}) diff --git a/web/shared/editor/extensions/image/index.ts b/web/shared/editor/extensions/image/index.ts new file mode 100644 index 00000000..61769c6d --- /dev/null +++ b/web/shared/editor/extensions/image/index.ts @@ -0,0 +1 @@ +export * from "./image" diff --git a/web/shared/la-editor/extensions/link/index.ts b/web/shared/editor/extensions/link/index.ts similarity index 100% rename from web/shared/la-editor/extensions/link/index.ts rename to web/shared/editor/extensions/link/index.ts diff --git a/web/shared/la-editor/extensions/link/link.ts b/web/shared/editor/extensions/link/link.ts similarity index 100% rename from web/shared/la-editor/extensions/link/link.ts rename to web/shared/editor/extensions/link/link.ts diff --git a/web/shared/la-editor/extensions/ordered-list/index.ts b/web/shared/editor/extensions/ordered-list/index.ts similarity index 100% rename from web/shared/la-editor/extensions/ordered-list/index.ts rename to web/shared/editor/extensions/ordered-list/index.ts diff --git a/web/shared/la-editor/extensions/ordered-list/ordered-list.ts b/web/shared/editor/extensions/ordered-list/ordered-list.ts similarity index 100% rename from web/shared/la-editor/extensions/ordered-list/ordered-list.ts rename to web/shared/editor/extensions/ordered-list/ordered-list.ts diff --git a/web/shared/la-editor/extensions/paragraph/index.ts b/web/shared/editor/extensions/paragraph/index.ts similarity index 100% rename from web/shared/la-editor/extensions/paragraph/index.ts rename to web/shared/editor/extensions/paragraph/index.ts diff --git a/web/shared/la-editor/extensions/paragraph/paragraph.ts b/web/shared/editor/extensions/paragraph/paragraph.ts similarity index 100% rename from web/shared/la-editor/extensions/paragraph/paragraph.ts rename to web/shared/editor/extensions/paragraph/paragraph.ts diff --git a/web/shared/la-editor/extensions/selection/index.ts b/web/shared/editor/extensions/selection/index.ts similarity index 100% rename from web/shared/la-editor/extensions/selection/index.ts rename to web/shared/editor/extensions/selection/index.ts diff --git a/web/shared/la-editor/extensions/selection/selection.ts b/web/shared/editor/extensions/selection/selection.ts similarity index 100% rename from web/shared/la-editor/extensions/selection/selection.ts rename to web/shared/editor/extensions/selection/selection.ts diff --git a/web/shared/la-editor/extensions/slash-command/groups.ts b/web/shared/editor/extensions/slash-command/groups.ts similarity index 91% rename from web/shared/la-editor/extensions/slash-command/groups.ts rename to web/shared/editor/extensions/slash-command/groups.ts index 7e227c34..4f983089 100644 --- a/web/shared/la-editor/extensions/slash-command/groups.ts +++ b/web/shared/editor/extensions/slash-command/groups.ts @@ -83,6 +83,17 @@ export const GROUPS: Group[] = [ name: "insert", title: "Insert", commands: [ + { + name: "image", + label: "Image", + iconName: "Image", + description: "Insert an image", + shortcuts: ["mod", "shift", "i"], + aliases: ["img"], + action: (editor) => { + editor.chain().focus().toggleImage().run() + }, + }, { name: "codeBlock", label: "Code Block", diff --git a/web/shared/la-editor/extensions/slash-command/index.ts b/web/shared/editor/extensions/slash-command/index.ts similarity index 100% rename from web/shared/la-editor/extensions/slash-command/index.ts rename to web/shared/editor/extensions/slash-command/index.ts diff --git a/web/shared/la-editor/extensions/slash-command/menu-list.tsx b/web/shared/editor/extensions/slash-command/menu-list.tsx similarity index 99% rename from web/shared/la-editor/extensions/slash-command/menu-list.tsx rename to web/shared/editor/extensions/slash-command/menu-list.tsx index 5760fc68..d6ad5438 100644 --- a/web/shared/la-editor/extensions/slash-command/menu-list.tsx +++ b/web/shared/editor/extensions/slash-command/menu-list.tsx @@ -6,10 +6,10 @@ import { Button } from "@/components/ui/button" import { Separator } from "@/components/ui/separator" import { Command, MenuListProps } from "./types" -import { getShortcutKeys } from "@/lib/utils" import { Icon } from "../../components/ui/icon" import { PopoverWrapper } from "../../components/ui/popover-wrapper" import { Shortcut } from "../../components/ui/shortcut" +import { getShortcutKeys } from "@shared/utils" export const MenuList = React.forwardRef((props: MenuListProps, ref) => { const scrollContainer = React.useRef(null) diff --git a/web/shared/la-editor/extensions/slash-command/slash-command.ts b/web/shared/editor/extensions/slash-command/slash-command.ts similarity index 100% rename from web/shared/la-editor/extensions/slash-command/slash-command.ts rename to web/shared/editor/extensions/slash-command/slash-command.ts diff --git a/web/shared/la-editor/extensions/slash-command/types.ts b/web/shared/editor/extensions/slash-command/types.ts similarity index 100% rename from web/shared/la-editor/extensions/slash-command/types.ts rename to web/shared/editor/extensions/slash-command/types.ts diff --git a/web/shared/la-editor/extensions/starter-kit.ts b/web/shared/editor/extensions/starter-kit.ts similarity index 100% rename from web/shared/la-editor/extensions/starter-kit.ts rename to web/shared/editor/extensions/starter-kit.ts diff --git a/web/shared/la-editor/extensions/task-item/components/task-item-view.tsx b/web/shared/editor/extensions/task-item/components/task-item-view.tsx similarity index 100% rename from web/shared/la-editor/extensions/task-item/components/task-item-view.tsx rename to web/shared/editor/extensions/task-item/components/task-item-view.tsx diff --git a/web/shared/la-editor/extensions/task-item/index.ts b/web/shared/editor/extensions/task-item/index.ts similarity index 100% rename from web/shared/la-editor/extensions/task-item/index.ts rename to web/shared/editor/extensions/task-item/index.ts diff --git a/web/shared/la-editor/extensions/task-item/task-item.ts b/web/shared/editor/extensions/task-item/task-item.ts similarity index 100% rename from web/shared/la-editor/extensions/task-item/task-item.ts rename to web/shared/editor/extensions/task-item/task-item.ts diff --git a/web/shared/la-editor/extensions/task-list/index.ts b/web/shared/editor/extensions/task-list/index.ts similarity index 100% rename from web/shared/la-editor/extensions/task-list/index.ts rename to web/shared/editor/extensions/task-list/index.ts diff --git a/web/shared/la-editor/extensions/task-list/task-list.ts b/web/shared/editor/extensions/task-list/task-list.ts similarity index 100% rename from web/shared/la-editor/extensions/task-list/task-list.ts rename to web/shared/editor/extensions/task-list/task-list.ts diff --git a/web/shared/editor/hooks/use-la-editor.ts b/web/shared/editor/hooks/use-la-editor.ts new file mode 100644 index 00000000..ebdc1c0e --- /dev/null +++ b/web/shared/editor/hooks/use-la-editor.ts @@ -0,0 +1,254 @@ +import * as React from "react" +import type { Editor } from "@tiptap/core" +import type { Content, EditorOptions, UseEditorOptions } from "@tiptap/react" +import { useEditor } from "@tiptap/react" +import { cn } from "@/lib/utils" +import { useThrottleCallback } from "@shared/hooks/use-throttle-callback" +import { getOutput } from "@shared/editor/lib/utils" +import { StarterKit } from "@shared/editor/extensions/starter-kit" +import { TaskList } from "@shared/editor/extensions/task-list" +import { TaskItem } from "@shared/editor/extensions/task-item" +import { HorizontalRule } from "@shared/editor/extensions/horizontal-rule" +import { Blockquote } from "@shared/editor/extensions/blockquote/blockquote" +import { SlashCommand } from "@shared/editor/extensions/slash-command" +import { Heading } from "@shared/editor/extensions/heading" +import { Link } from "@shared/editor/extensions/link" +import { CodeBlockLowlight } from "@shared/editor/extensions/code-block-lowlight" +import { Selection } from "@shared/editor/extensions/selection" +import { Code } from "@shared/editor/extensions/code" +import { Paragraph } from "@shared/editor/extensions/paragraph" +import { BulletList } from "@shared/editor/extensions/bullet-list" +import { OrderedList } from "@shared/editor/extensions/ordered-list" +import { Dropcursor } from "@shared/editor/extensions/dropcursor" +import { Image } from "../extensions/image" +import { FileHandler } from "../extensions/file-handler" +import { toast } from "sonner" +import { useAccount } from "~/lib/providers/jazz-provider" +import { ImageLists } from "~/lib/schema/folder" +import { LaAccount, Image as LaImage } from "~/lib/schema" +import { storeImageFn } from "@shared/actions" + +export interface UseLaEditorProps + extends Omit { + value?: Content + output?: "html" | "json" | "text" + placeholder?: string + editorClassName?: string + throttleDelay?: number + onUpdate?: (content: Content) => void + onBlur?: (content: Content) => void + editorProps?: EditorOptions["editorProps"] +} + +const createExtensions = ({ + me, + placeholder, +}: { + me?: LaAccount + placeholder: string +}) => [ + Heading, + Code, + Link, + TaskList, + TaskItem, + Selection, + Paragraph, + Image.configure({ + allowedMimeTypes: ["image/*"], + maxFileSize: 5 * 1024 * 1024, + allowBase64: true, + uploadFn: async (blobUrl) => { + const uniqueId = Math.random().toString(36).substring(7) + const response = await fetch(blobUrl) + const blob = await response.blob() + + const file = new File([blob], `${uniqueId}`, { type: blob.type }) + + const formData = new FormData() + formData.append("file", file) + + const store = await storeImageFn(formData) + + if (me) { + if (!me.root?.images) { + me.root!.images = ImageLists.create([], { owner: me }) + } + + const img = LaImage.create( + { + url: store.fileModel.content.src, + createdAt: new Date(), + updatedAt: new Date(), + }, + { owner: me }, + ) + + me.root!.images.push(img) + } + + return store.fileModel.content.src + }, + onToggle(editor, files, pos) { + files.forEach((file) => + editor.commands.insertContentAt(pos, { + type: "image", + attrs: { src: URL.createObjectURL(file) }, + }), + ) + }, + onValidationError(errors) { + errors.forEach((error) => { + toast.error("Image validation error", { + position: "bottom-right", + description: error.reason, + }) + }) + }, + onActionSuccess({ action }) { + const mapping = { + copyImage: "Copy Image", + copyLink: "Copy Link", + download: "Download", + } + toast.success(mapping[action], { + position: "bottom-right", + description: "Image action success", + }) + }, + onActionError(error, { action }) { + const mapping = { + copyImage: "Copy Image", + copyLink: "Copy Link", + download: "Download", + } + toast.error(`Failed to ${mapping[action]}`, { + position: "bottom-right", + description: error.message, + }) + }, + }), + FileHandler.configure({ + allowBase64: true, + allowedMimeTypes: ["image/*"], + maxFileSize: 5 * 1024 * 1024, + onDrop: (editor, files, pos) => { + files.forEach((file) => + editor.commands.insertContentAt(pos, { + type: "image", + attrs: { src: URL.createObjectURL(file) }, + }), + ) + }, + onPaste: (editor, files) => { + files.forEach((file) => + editor.commands.insertContent({ + type: "image", + attrs: { src: URL.createObjectURL(file) }, + }), + ) + }, + onValidationError: (errors) => { + errors.forEach((error) => { + console.log("File validation error", error) + }) + }, + }), + Dropcursor, + Blockquote, + BulletList, + OrderedList, + SlashCommand, + HorizontalRule, + CodeBlockLowlight, + StarterKit.configure({ + placeholder: { + placeholder: () => placeholder, + }, + }), +] + +export const useLaEditor = ({ + value, + output = "html", + placeholder = "", + editorClassName, + throttleDelay = 0, + onUpdate, + onBlur, + editorProps, + ...props +}: UseLaEditorProps) => { + const { me } = useAccount({ root: { images: [] } }) + + const throttledSetValue = useThrottleCallback( + (editor: Editor) => { + const content = getOutput(editor, output) + onUpdate?.(content) + }, + [output, onUpdate], + throttleDelay, + ) + + const handleCreate = React.useCallback( + (editor: Editor) => { + if (value) { + editor.commands.setContent(value) + } + }, + [value], + ) + + const handleBlur = React.useCallback( + (editor: Editor) => { + const content = getOutput(editor, output) + onBlur?.(content) + }, + [output, onBlur], + ) + + const mergedEditorProps = React.useMemo(() => { + const defaultEditorProps: EditorOptions["editorProps"] = { + attributes: { + autocomplete: "off", + autocorrect: "off", + autocapitalize: "off", + class: cn("focus:outline-none", editorClassName), + }, + } + + if (!editorProps) return defaultEditorProps + + return { + ...defaultEditorProps, + ...editorProps, + attributes: { + ...defaultEditorProps.attributes, + ...editorProps.attributes, + }, + } + }, [editorProps, editorClassName]) + + const editorOptions: UseEditorOptions = React.useMemo( + () => ({ + extensions: createExtensions({ me, placeholder }), + editorProps: mergedEditorProps, + onUpdate: ({ editor }) => throttledSetValue(editor), + onCreate: ({ editor }) => handleCreate(editor), + onBlur: ({ editor }) => handleBlur(editor), + ...props, + }), + [ + placeholder, + mergedEditorProps, + throttledSetValue, + handleCreate, + handleBlur, + props, + ], + ) + + return useEditor(editorOptions) +} + +export default useLaEditor diff --git a/web/shared/la-editor/hooks/use-text-menu-commands.ts b/web/shared/editor/hooks/use-text-menu-commands.ts similarity index 100% rename from web/shared/la-editor/hooks/use-text-menu-commands.ts rename to web/shared/editor/hooks/use-text-menu-commands.ts diff --git a/web/shared/la-editor/hooks/use-text-menu-states.ts b/web/shared/editor/hooks/use-text-menu-states.ts similarity index 100% rename from web/shared/la-editor/hooks/use-text-menu-states.ts rename to web/shared/editor/hooks/use-text-menu-states.ts diff --git a/web/shared/editor/index.ts b/web/shared/editor/index.ts new file mode 100644 index 00000000..bc7a171a --- /dev/null +++ b/web/shared/editor/index.ts @@ -0,0 +1 @@ +export * from "./editor" diff --git a/web/shared/editor/lib/utils/index.ts b/web/shared/editor/lib/utils/index.ts new file mode 100644 index 00000000..162d92c0 --- /dev/null +++ b/web/shared/editor/lib/utils/index.ts @@ -0,0 +1,187 @@ +import { LaEditorProps } from "@shared/editor" +import { Editor } from "@tiptap/core" + +export function getOutput(editor: Editor, format: LaEditorProps["output"]) { + if (format === "json") { + return editor.getJSON() + } + + if (format === "html") { + return editor.getText() ? editor.getHTML() : "" + } + + return editor.getText() +} + +export type FileError = { + file: File | string + reason: "type" | "size" | "invalidBase64" | "base64NotAllowed" +} + +export type FileValidationOptions = { + allowedMimeTypes: string[] + maxFileSize?: number + allowBase64: boolean +} + +type FileInput = File | { src: string | File; alt?: string; title?: string } + +// URL validation and sanitization +export const isUrl = ( + text: string, + options?: { requireHostname: boolean; allowBase64?: boolean }, +): boolean => { + if (text.match(/\n/)) return false + + try { + const url = new URL(text) + const blockedProtocols = [ + "javascript:", + "file:", + "vbscript:", + ...(options?.allowBase64 ? [] : ["data:"]), + ] + + if (blockedProtocols.includes(url.protocol)) return false + if (options?.allowBase64 && url.protocol === "data:") + return /^data:image\/[a-z]+;base64,/.test(text) + if (url.hostname) return true + + return ( + url.protocol !== "" && + (url.pathname.startsWith("//") || url.pathname.startsWith("http")) && + !options?.requireHostname + ) + } catch { + return false + } +} + +export const sanitizeUrl = ( + url: string | null | undefined, + options?: { allowBase64?: boolean }, +): string | undefined => { + if (!url) return undefined + + if (options?.allowBase64 && url.startsWith("data:image")) { + return isUrl(url, { requireHostname: false, allowBase64: true }) + ? url + : undefined + } + + const isValidUrl = isUrl(url, { + requireHostname: false, + allowBase64: options?.allowBase64, + }) + const isSpecialProtocol = /^(\/|#|mailto:|sms:|fax:|tel:)/.test(url) + + return isValidUrl || isSpecialProtocol ? url : `https://${url}` +} + +// File handling +export async function blobUrlToBase64(blobUrl: string): Promise { + const response = await fetch(blobUrl) + const blob = await response.blob() + + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => { + if (typeof reader.result === "string") { + resolve(reader.result) + } else { + reject(new Error("Failed to convert Blob to base64")) + } + } + reader.onerror = reject + reader.readAsDataURL(blob) + }) +} + +const validateFileOrBase64 = ( + input: File | string, + options: FileValidationOptions, + originalFile: T, + validFiles: T[], + errors: FileError[], +) => { + const { isValidType, isValidSize } = checkTypeAndSize(input, options) + + if (isValidType && isValidSize) { + validFiles.push(originalFile) + } else { + if (!isValidType) errors.push({ file: input, reason: "type" }) + if (!isValidSize) errors.push({ file: input, reason: "size" }) + } +} + +const checkTypeAndSize = ( + input: File | string, + { allowedMimeTypes, maxFileSize }: FileValidationOptions, +) => { + const mimeType = input instanceof File ? input.type : base64MimeType(input) + const size = + input instanceof File ? input.size : atob(input.split(",")[1]).length + + const isValidType = + allowedMimeTypes.length === 0 || + allowedMimeTypes.includes(mimeType) || + allowedMimeTypes.includes(`${mimeType.split("/")[0]}/*`) + + const isValidSize = !maxFileSize || size <= maxFileSize + + return { isValidType, isValidSize } +} + +const base64MimeType = (encoded: string): string => { + const result = encoded.match(/data:([a-zA-Z0-9]+\/[a-zA-Z0-9-.+]+).*,.*/) + return result && result.length ? result[1] : "unknown" +} + +const isBase64 = (str: string): boolean => { + if (str.startsWith("data:")) { + const matches = str.match(/^data:[^;]+;base64,(.+)$/) + if (matches && matches[1]) { + str = matches[1] + } else { + return false + } + } + + try { + atob(str) + return true + } catch { + return false + } +} + +export const filterFiles = ( + files: T[], + options: FileValidationOptions, +): [T[], FileError[]] => { + const validFiles: T[] = [] + const errors: FileError[] = [] + + files.forEach((file) => { + const actualFile = "src" in file ? file.src : file + + if (actualFile instanceof File) { + validateFileOrBase64(actualFile, options, file, validFiles, errors) + } else if (typeof actualFile === "string") { + if (isBase64(actualFile)) { + if (options.allowBase64) { + validateFileOrBase64(actualFile, options, file, validFiles, errors) + } else { + errors.push({ file: actualFile, reason: "base64NotAllowed" }) + } + } else { + validFiles.push(file) + } + } + }) + + return [validFiles, errors] +} + +export * from "./isCustomNodeSelected" +export * from "./isTextSelected" diff --git a/web/shared/la-editor/lib/utils/isCustomNodeSelected.ts b/web/shared/editor/lib/utils/isCustomNodeSelected.ts similarity index 100% rename from web/shared/la-editor/lib/utils/isCustomNodeSelected.ts rename to web/shared/editor/lib/utils/isCustomNodeSelected.ts diff --git a/web/shared/la-editor/lib/utils/isTextSelected.ts b/web/shared/editor/lib/utils/isTextSelected.ts similarity index 100% rename from web/shared/la-editor/lib/utils/isTextSelected.ts rename to web/shared/editor/lib/utils/isTextSelected.ts diff --git a/web/shared/la-editor/styles/index.css b/web/shared/editor/styles/index.css similarity index 87% rename from web/shared/la-editor/styles/index.css rename to web/shared/editor/styles/index.css index 8173c7b3..03f4c72d 100644 --- a/web/shared/la-editor/styles/index.css +++ b/web/shared/editor/styles/index.css @@ -4,3 +4,4 @@ @import "partials/lists.css"; @import "partials/typography.css"; @import "partials/misc.css"; +@import "partials/zoom.css"; diff --git a/web/shared/la-editor/styles/partials/code-highlight.css b/web/shared/editor/styles/partials/code-highlight.css similarity index 100% rename from web/shared/la-editor/styles/partials/code-highlight.css rename to web/shared/editor/styles/partials/code-highlight.css diff --git a/web/shared/la-editor/styles/partials/code.css b/web/shared/editor/styles/partials/code.css similarity index 100% rename from web/shared/la-editor/styles/partials/code.css rename to web/shared/editor/styles/partials/code.css diff --git a/web/shared/la-editor/styles/partials/lists.css b/web/shared/editor/styles/partials/lists.css similarity index 93% rename from web/shared/la-editor/styles/partials/lists.css rename to web/shared/editor/styles/partials/lists.css index efc05a22..cce1615e 100644 --- a/web/shared/la-editor/styles/partials/lists.css +++ b/web/shared/editor/styles/partials/lists.css @@ -1,7 +1,3 @@ -.la-editor div.tiptap p { - @apply text-[var(--la-font-size-regular)]; -} - .la-editor .ProseMirror ol { @apply list-decimal; } @@ -68,7 +64,7 @@ } .la-editor .ProseMirror .taskItem-checkbox:checked { - @apply border-primary bg-primary border bg-no-repeat; + @apply border border-primary bg-primary bg-no-repeat; background-image: var(--checkbox-bg-image); } diff --git a/web/shared/la-editor/styles/partials/misc.css b/web/shared/editor/styles/partials/misc.css similarity index 86% rename from web/shared/la-editor/styles/partials/misc.css rename to web/shared/editor/styles/partials/misc.css index 0f075201..bff95c55 100644 --- a/web/shared/la-editor/styles/partials/misc.css +++ b/web/shared/editor/styles/partials/misc.css @@ -16,7 +16,3 @@ content: attr(data-placeholder); @apply pointer-events-none float-left h-0 text-[var(--la-secondary)]; } - -.la-editor div.tiptap p { - @apply text-[var(--la-font-size-regular)]; -} diff --git a/web/shared/la-editor/styles/partials/prosemirror-base.css b/web/shared/editor/styles/partials/prosemirror-base.css similarity index 90% rename from web/shared/la-editor/styles/partials/prosemirror-base.css rename to web/shared/editor/styles/partials/prosemirror-base.css index cf1c3d5d..6115c84a 100644 --- a/web/shared/la-editor/styles/partials/prosemirror-base.css +++ b/web/shared/editor/styles/partials/prosemirror-base.css @@ -43,7 +43,7 @@ .la-editor .ProseMirror blockquote::before, .la-editor .ProseMirror blockquote.is-empty::before { - @apply bg-accent-foreground/15 absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm content-['']; + @apply absolute bottom-0 left-0 top-0 h-full w-1 rounded-sm bg-accent-foreground/15 content-['']; } .la-editor .ProseMirror hr { @@ -51,7 +51,7 @@ } .la-editor .ProseMirror-focused hr.ProseMirror-selectednode { - @apply outline-muted-foreground rounded-full outline outline-2 outline-offset-1; + @apply rounded-full outline outline-2 outline-offset-1 outline-muted-foreground; } .la-editor .ProseMirror .ProseMirror-gapcursor { diff --git a/web/shared/la-editor/styles/partials/typography.css b/web/shared/editor/styles/partials/typography.css similarity index 93% rename from web/shared/la-editor/styles/partials/typography.css rename to web/shared/editor/styles/partials/typography.css index ac087040..3d6a8c29 100644 --- a/web/shared/la-editor/styles/partials/typography.css +++ b/web/shared/editor/styles/partials/typography.css @@ -19,7 +19,7 @@ } .la-editor .ProseMirror a.link { - @apply text-primary cursor-pointer; + @apply cursor-pointer text-primary; } .la-editor .ProseMirror a.link:hover { diff --git a/web/shared/la-editor/styles/partials/vars.css b/web/shared/editor/styles/partials/vars.css similarity index 90% rename from web/shared/la-editor/styles/partials/vars.css rename to web/shared/editor/styles/partials/vars.css index b199c2d7..b909f9bd 100644 --- a/web/shared/la-editor/styles/partials/vars.css +++ b/web/shared/editor/styles/partials/vars.css @@ -1,5 +1,7 @@ :root { - --la-font-size-regular: 0.9375rem; + --mt-overlay: rgba(251, 251, 251, 0.75); + --mt-transparent-foreground: rgba(0, 0, 0, 0.4); + --mt-bg-secondary: rgba(251, 251, 251, 0.8); --checkbox-bg-image: url("data:image/svg+xml;utf8,%3Csvg%20width=%2210%22%20height=%229%22%20viewBox=%220%200%2010%208%22%20xmlns=%22http://www.w3.org/2000/svg%22%20fill=%22%23fbfbfb%22%3E%3Cpath%20d=%22M3.46975%205.70757L1.88358%204.1225C1.65832%203.8974%201.29423%203.8974%201.06897%204.1225C0.843675%204.34765%200.843675%204.7116%201.06897%204.93674L3.0648%206.93117C3.29006%207.15628%203.65414%207.15628%203.8794%206.93117L8.93103%201.88306C9.15633%201.65792%209.15633%201.29397%208.93103%201.06883C8.70578%200.843736%208.34172%200.843724%208.11646%201.06879C8.11645%201.0688%208.11643%201.06882%208.11642%201.06883L3.46975%205.70757Z%22%20stroke-width=%220.2%22%20/%3E%3C/svg%3E"); --la-code-background: rgba(8, 43, 120, 0.047); @@ -23,7 +25,9 @@ } .dark .ProseMirror { - --la-font-size-regular: 0.9375rem; + --mt-overlay: rgba(31, 32, 35, 0.75); + --mt-transparent-foreground: rgba(255, 255, 255, 0.4); + --mt-bg-secondary: rgba(31, 32, 35, 0.8); --checkbox-bg-image: url("data:image/svg+xml;utf8,%3Csvg%20width=%2210%22%20height=%229%22%20viewBox=%220%200%2010%208%22%20xmlns=%22http://www.w3.org/2000/svg%22%20fill=%22lch%284.8%25%200.7%20272%29%22%3E%3Cpath%20d=%22M3.46975%205.70757L1.88358%204.1225C1.65832%203.8974%201.29423%203.8974%201.06897%204.1225C0.843675%204.34765%200.843675%204.7116%201.06897%204.93674L3.0648%206.93117C3.29006%207.15628%203.65414%207.15628%203.8794%206.93117L8.93103%201.88306C9.15633%201.65792%209.15633%201.29397%208.93103%201.06883C8.70578%200.843736%208.34172%200.843724%208.11646%201.06879C8.11645%201.0688%208.11643%201.06882%208.11642%201.06883L3.46975%205.70757Z%22%20stroke-width=%220.2%22%20/%3E%3C/svg%3E"); --la-code-background: rgba(255, 255, 255, 0.075); diff --git a/web/shared/editor/styles/partials/zoom.css b/web/shared/editor/styles/partials/zoom.css new file mode 100644 index 00000000..9225663c --- /dev/null +++ b/web/shared/editor/styles/partials/zoom.css @@ -0,0 +1,94 @@ +[data-rmiz-ghost] { + position: absolute; + pointer-events: none; +} +[data-rmiz-btn-zoom], +[data-rmiz-btn-unzoom] { + background-color: rgba(0, 0, 0, 0.7); + border-radius: 50%; + border: none; + box-shadow: 0 0 1px rgba(255, 255, 255, 0.5); + color: #fff; + height: 40px; + margin: 0; + outline-offset: 2px; + padding: 9px; + touch-action: manipulation; + width: 40px; + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} +[data-rmiz-btn-zoom]:not(:focus):not(:active) { + position: absolute; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + pointer-events: none; + white-space: nowrap; + width: 1px; +} +[data-rmiz-btn-zoom] { + position: absolute; + inset: 10px 10px auto auto; + cursor: zoom-in; +} +[data-rmiz-btn-unzoom] { + position: absolute; + inset: 20px 20px auto auto; + cursor: zoom-out; + z-index: 1; +} +[data-rmiz-content="found"] img, +[data-rmiz-content="found"] svg, +[data-rmiz-content="found"] [role="img"], +[data-rmiz-content="found"] [data-zoom] { + cursor: inherit; +} +[data-rmiz-modal]::backdrop { + display: none; +} +[data-rmiz-modal][open] { + position: fixed; + width: 100vw; + width: 100dvw; + height: 100vh; + height: 100dvh; + max-width: none; + max-height: none; + margin: 0; + padding: 0; + border: 0; + background: transparent; + overflow: hidden; +} +[data-rmiz-modal-overlay] { + position: absolute; + inset: 0; + transition: background-color 0.3s; +} +[data-rmiz-modal-overlay="hidden"] { + background-color: rgba(255, 255, 255, 0); +} +[data-rmiz-modal-overlay="visible"] { + background-color: rgba(255, 255, 255, 1); +} +[data-rmiz-modal-content] { + position: relative; + width: 100%; + height: 100%; +} +[data-rmiz-modal-img] { + position: absolute; + cursor: zoom-out; + image-rendering: high-quality; + transform-origin: top left; + transition: transform 0.3s; +} +@media (prefers-reduced-motion: reduce) { + [data-rmiz-modal-overlay], + [data-rmiz-modal-img] { + transition-duration: 0.01ms !important; + } +} diff --git a/web/shared/la-editor/types.ts b/web/shared/editor/types.ts similarity index 100% rename from web/shared/la-editor/types.ts rename to web/shared/editor/types.ts diff --git a/web/shared/hooks/use-throttle-callback.ts b/web/shared/hooks/use-throttle-callback.ts new file mode 100644 index 00000000..6d7fc7ad --- /dev/null +++ b/web/shared/hooks/use-throttle-callback.ts @@ -0,0 +1,53 @@ +import { useCallback, useRef, useEffect } from "react" + +type AnyFunction = (...args: any[]) => any + +export function useThrottleCallback( + callback: T, + deps: React.DependencyList, + delay: number, +): T { + const timeoutRef = useRef(null) + const lastCalledRef = useRef(0) + const callbackRef = useRef(callback) + + // Update the callback ref whenever the callback changes + useEffect(() => { + callbackRef.current = callback + }, [callback]) + + useEffect(() => { + // Cleanup function to clear the timeout + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + } + }, []) + + return useCallback( + (...args: Parameters) => { + const now = Date.now() + + if (now - lastCalledRef.current >= delay) { + // If enough time has passed, call the function immediately + lastCalledRef.current = now + callbackRef.current(...args) + } else { + // Otherwise, set a timeout to call the function later + if (timeoutRef.current) { + clearTimeout(timeoutRef.current) + } + + timeoutRef.current = setTimeout( + () => { + lastCalledRef.current = Date.now() + callbackRef.current(...args) + }, + delay - (now - lastCalledRef.current), + ) + } + }, + [delay, ...deps], + ) as T +} diff --git a/web/shared/la-editor/components/bubble-menu/bubble-menu.tsx b/web/shared/la-editor/components/bubble-menu/bubble-menu.tsx deleted file mode 100644 index a2b52262..00000000 --- a/web/shared/la-editor/components/bubble-menu/bubble-menu.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useTextmenuCommands } from "../../hooks/use-text-menu-commands" -import { PopoverWrapper } from "../ui/popover-wrapper" -import { useTextmenuStates } from "../../hooks/use-text-menu-states" -import { BubbleMenu as TiptapBubbleMenu, Editor } from "@tiptap/react" -import { ToolbarButton } from "../ui/toolbar-button" -import { Icon } from "../ui/icon" -import { Keybind } from "../ui/keybind" - -export type BubbleMenuProps = { - editor: Editor -} - -export const BubbleMenu = ({ editor }: BubbleMenuProps) => { - const commands = useTextmenuCommands(editor) - const states = useTextmenuStates(editor) - - const toolbarButtonClassname = - "hover:opacity-100 transition-all dark:border-slate-500/10 border-gray-400 hover:border-b-2 active:translate-y-0 hover:translate-y-[-1.5px] hover:bg-zinc-300 dark:hover:bg-neutral-800 shadow-md rounded-[10px]" - - return ( - - -
    - - - - - - - - - - - - - - - - - {/* - - */} - - - - - - - - - - - - - - {/* - - */} -
    -
    -
    - ) -} - -export default BubbleMenu diff --git a/web/shared/la-editor/components/ui/keybind.tsx b/web/shared/la-editor/components/ui/keybind.tsx deleted file mode 100644 index 38aab365..00000000 --- a/web/shared/la-editor/components/ui/keybind.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ReactNode, useState } from "react" -import { motion } from "framer-motion" - -export function Keybind({ - keys, - children, -}: { - keys: string[] - children: ReactNode -}) { - const [hovered, setHovered] = useState(false) - const variants = { - hidden: { opacity: 0, y: 6, x: "-50%" }, - visible: { opacity: 1, y: 0, x: "-50%" }, - } - return ( -
    setHovered(true)} - onMouseLeave={() => setHovered(false)} - className="group relative h-full" - > - - {keys.map((key, index) => ( - - {index > 0 && +} - {(() => { - switch (key.toLowerCase()) { - case "cmd": - return "⌘" - case "shift": - return "⇪" - - default: - return key - } - })()} - - ))} - - {children} -
    - ) -} diff --git a/web/shared/la-editor/extensions/index.ts b/web/shared/la-editor/extensions/index.ts deleted file mode 100644 index 4d4c5f0f..00000000 --- a/web/shared/la-editor/extensions/index.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { StarterKit } from "./starter-kit" -import { TaskList } from "./task-list" -import { TaskItem } from "./task-item" -import { HorizontalRule } from "./horizontal-rule" -import { Blockquote } from "./blockquote/blockquote" -import { SlashCommand } from "./slash-command" -import { Heading } from "./heading" -import { Link } from "./link" -import { CodeBlockLowlight } from "./code-block-lowlight" -import { Selection } from "./selection" -import { Code } from "./code" -import { Paragraph } from "./paragraph" -import { BulletList } from "./bullet-list" -import { OrderedList } from "./ordered-list" -import { Dropcursor } from "./dropcursor" - -export interface ExtensionOptions { - placeholder?: string -} - -export const createExtensions = ({ - placeholder = "Start typing...", -}: ExtensionOptions) => [ - Heading, - Code, - Link, - TaskList, - TaskItem, - Selection, - Paragraph, - Dropcursor, - Blockquote, - BulletList, - OrderedList, - SlashCommand, - HorizontalRule, - CodeBlockLowlight, - StarterKit.configure({ - placeholder: { - placeholder: () => placeholder, - }, - }), -] - -export default createExtensions diff --git a/web/shared/la-editor/index.ts b/web/shared/la-editor/index.ts deleted file mode 100644 index 3da90ddd..00000000 --- a/web/shared/la-editor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./la-editor" diff --git a/web/shared/la-editor/la-editor.tsx b/web/shared/la-editor/la-editor.tsx deleted file mode 100644 index 7c1884f7..00000000 --- a/web/shared/la-editor/la-editor.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import * as React from "react" -import { EditorContent, useEditor } from "@tiptap/react" -import { Editor, Content } from "@tiptap/core" -import { BubbleMenu } from "./components/bubble-menu" -import { createExtensions } from "./extensions" -import "./styles/index.css" -import { cn } from "@/lib/utils" -import { getOutput } from "./lib/utils" -import type { EditorView } from "@tiptap/pm/view" -import { useThrottle } from "@shared/hooks/use-throttle" - -export interface LAEditorProps - extends Omit, "value"> { - output?: "html" | "json" | "text" - placeholder?: string - editorClassName?: string - onUpdate?: (content: Content) => void - onBlur?: (content: Content) => void - handleKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean - value?: any - throttleDelay?: number -} - -export interface LAEditorRef { - editor: Editor | null -} - -export const LAEditor = React.forwardRef( - ( - { - value, - placeholder, - output = "html", - editorClassName, - className, - onUpdate, - onBlur, - handleKeyDown, - throttleDelay = 1000, - ...props - }, - ref, - ) => { - const throttledSetValue = useThrottle( - (value: Content) => onUpdate?.(value), - throttleDelay, - ) - - const handleUpdate = React.useCallback( - (editor: Editor) => { - throttledSetValue(getOutput(editor, output)) - }, - [output, throttledSetValue], - ) - - const editor = useEditor({ - autofocus: false, - immediatelyRender: false, - extensions: createExtensions({ placeholder }), - editorProps: { - attributes: { - autocomplete: "off", - autocorrect: "off", - autocapitalize: "off", - class: editorClassName || "", - }, - handleKeyDown, - }, - onCreate: ({ editor }) => { - editor.commands.setContent(value) - }, - onUpdate: ({ editor }) => handleUpdate(editor), - onBlur: ({ editor }) => { - onBlur?.(getOutput(editor, output)) - }, - }) - - React.useImperativeHandle( - ref, - () => ({ - editor: editor, - }), - [editor], - ) - - if (!editor) { - return null - } - - return ( -
    - - -
    - ) - }, -) - -LAEditor.displayName = "LAEditor" - -export default LAEditor diff --git a/web/shared/la-editor/lib/utils/index.ts b/web/shared/la-editor/lib/utils/index.ts deleted file mode 100644 index 32a92140..00000000 --- a/web/shared/la-editor/lib/utils/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Editor } from "@tiptap/core" -import { LAEditorProps } from "../../la-editor" - -export function getOutput(editor: Editor, output: LAEditorProps["output"]) { - if (output === "html") return editor.getHTML() - if (output === "json") return editor.getJSON() - if (output === "text") return editor.getText() - return "" -} - -export * from "./isCustomNodeSelected" -export * from "./isTextSelected" diff --git a/web/shared/minimal-tiptap/components/image/image-edit-block.tsx b/web/shared/minimal-tiptap/components/image/image-edit-block.tsx index 1bd303e3..88121adf 100644 --- a/web/shared/minimal-tiptap/components/image/image-edit-block.tsx +++ b/web/shared/minimal-tiptap/components/image/image-edit-block.tsx @@ -113,7 +113,7 @@ const ImageEditBlock = ({ onChange={handleFile} /> {error && ( -
    +
    {error}
    )} diff --git a/web/shared/utils/index.ts b/web/shared/utils/index.ts new file mode 100644 index 00000000..279e0923 --- /dev/null +++ b/web/shared/utils/index.ts @@ -0,0 +1,5 @@ +export const isClient = () => typeof window !== "undefined" + +export const isServer = () => !isClient() + +export * from "./keyboard" diff --git a/web/app/lib/utils/keyboard.ts b/web/shared/utils/keyboard.ts similarity index 97% rename from web/app/lib/utils/keyboard.ts rename to web/shared/utils/keyboard.ts index d5f99db6..366a080d 100644 --- a/web/app/lib/utils/keyboard.ts +++ b/web/shared/utils/keyboard.ts @@ -1,4 +1,4 @@ -import { isServer } from "." +import { isServer } from "@shared/utils" interface ShortcutKeyResult { symbol: string diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index d732033a..2b77a152 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -3,7 +3,11 @@ import { fontFamily } from "tailwindcss/defaultTheme" const config = { darkMode: ["class"], - content: ["./app/**/*.{js,ts,jsx,tsx}", "./shared/**/*.{js,ts,jsx,tsx}"], + content: [ + "./app/**/*.{js,ts,jsx,tsx}", + "./shared/**/*.{js,ts,jsx,tsx}", + "../web/app/**/*.{js,ts,jsx,tsx}", + ], prefix: "", safelist: [".dark"], theme: {