From 256cc9592a3f31fc392e1ccdb699aa206b7b47ce Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 26 Aug 2023 23:49:28 +0800 Subject: ... --- FrontEnd/src/components/hooks/index.ts | 3 ++ FrontEnd/src/components/hooks/responsive.ts | 7 ++++ FrontEnd/src/components/hooks/useClickOutside.ts | 38 +++++++++++++++++++ FrontEnd/src/components/hooks/useScrollToBottom.ts | 44 ++++++++++++++++++++++ 4 files changed, 92 insertions(+) create mode 100644 FrontEnd/src/components/hooks/index.ts create mode 100644 FrontEnd/src/components/hooks/responsive.ts create mode 100644 FrontEnd/src/components/hooks/useClickOutside.ts create mode 100644 FrontEnd/src/components/hooks/useScrollToBottom.ts (limited to 'FrontEnd/src/components/hooks') diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts new file mode 100644 index 00000000..3c9859bc --- /dev/null +++ b/FrontEnd/src/components/hooks/index.ts @@ -0,0 +1,3 @@ +export { useMobile } from "./responsive"; +export { default as useClickOutside } from "./useClickOutside"; +export { default as useScrollToBottom } from "./useScrollToBottom"; diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts new file mode 100644 index 00000000..6bcce96c --- /dev/null +++ b/FrontEnd/src/components/hooks/responsive.ts @@ -0,0 +1,7 @@ +import { useMediaQuery } from "react-responsive"; + +import { breakpoints } from "../breakpoints"; + +export function useMobile(): boolean { + return useMediaQuery({ maxWidth: breakpoints.sm }); +} diff --git a/FrontEnd/src/components/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts new file mode 100644 index 00000000..828ce7e3 --- /dev/null +++ b/FrontEnd/src/components/hooks/useClickOutside.ts @@ -0,0 +1,38 @@ +import { useRef, useEffect } from "react"; + +export default function useClickOutside( + element: HTMLElement | null | undefined, + onClickOutside: () => void, + nextTick?: boolean, +): void { + const onClickOutsideRef = useRef<() => void>(onClickOutside); + + useEffect(() => { + onClickOutsideRef.current = onClickOutside; + }, [onClickOutside]); + + useEffect(() => { + if (element != null) { + const handler = (event: MouseEvent): void => { + let e: HTMLElement | null = event.target as HTMLElement; + while (e) { + if (e == element) { + return; + } + e = e.parentElement; + } + onClickOutsideRef.current(); + }; + if (nextTick) { + setTimeout(() => { + document.addEventListener("click", handler); + }); + } else { + document.addEventListener("click", handler); + } + return () => { + document.removeEventListener("click", handler); + }; + } + }, [element, nextTick]); +} diff --git a/FrontEnd/src/components/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts new file mode 100644 index 00000000..79fcda16 --- /dev/null +++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts @@ -0,0 +1,44 @@ +import { useRef, useEffect } from "react"; +import { fromEvent, filter, throttleTime } from "rxjs"; + +function useScrollToBottom( + handler: () => void, + enable = true, + option = { + maxOffset: 5, + throttle: 1000, + }, +): void { + const handlerRef = useRef<(() => void) | null>(null); + + useEffect(() => { + handlerRef.current = handler; + + return () => { + handlerRef.current = null; + }; + }, [handler]); + + useEffect(() => { + const subscription = fromEvent(window, "scroll") + .pipe( + filter( + () => + window.scrollY >= + document.body.scrollHeight - window.innerHeight - option.maxOffset, + ), + throttleTime(option.throttle), + ) + .subscribe(() => { + if (enable) { + handlerRef.current?.(); + } + }); + + return () => { + subscription.unsubscribe(); + }; + }, [enable, option.maxOffset, option.throttle]); +} + +export default useScrollToBottom; -- cgit v1.2.3 From 6664fb3506b1ea4af712fa849bd7c761a06c9843 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 31 Aug 2023 23:56:13 +0800 Subject: ... --- FrontEnd/src/components/dialog/Dialog.tsx | 4 +- FrontEnd/src/components/dialog/FullPageDialog.css | 28 +-- FrontEnd/src/components/dialog/FullPageDialog.tsx | 80 +++++---- FrontEnd/src/components/hooks/responsive.ts | 4 +- FrontEnd/src/pages/timeline/CollapseButton.tsx | 25 --- FrontEnd/src/pages/timeline/Timeline.tsx | 4 +- FrontEnd/src/pages/timeline/TimelineCard.css | 63 ------- FrontEnd/src/pages/timeline/TimelineCard.tsx | 158 ----------------- FrontEnd/src/pages/timeline/TimelineInfoCard.css | 63 +++++++ FrontEnd/src/pages/timeline/TimelineInfoCard.tsx | 199 ++++++++++++++++++++++ 10 files changed, 314 insertions(+), 314 deletions(-) delete mode 100644 FrontEnd/src/pages/timeline/CollapseButton.tsx delete mode 100644 FrontEnd/src/pages/timeline/TimelineCard.css delete mode 100644 FrontEnd/src/pages/timeline/TimelineCard.tsx create mode 100644 FrontEnd/src/pages/timeline/TimelineInfoCard.css create mode 100644 FrontEnd/src/pages/timeline/TimelineInfoCard.tsx (limited to 'FrontEnd/src/components/hooks') diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx index 85e8ca46..043a8eec 100644 --- a/FrontEnd/src/components/dialog/Dialog.tsx +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -2,7 +2,7 @@ import { ReactNode, useRef } from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; -import { ThemeColor } from "../common"; +import { ThemeColor, UiLogicError } from "../common"; import { useCloseDialog } from "./DialogProvider"; @@ -10,7 +10,7 @@ import "./Dialog.css"; const optionalPortalElement = document.getElementById("portal"); if (optionalPortalElement == null) { - throw new Error("Portal element not found"); + throw new UiLogicError(); } const portalElement = optionalPortalElement; diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css index 2f1fc636..ce07c6ac 100644 --- a/FrontEnd/src/components/dialog/FullPageDialog.css +++ b/FrontEnd/src/components/dialog/FullPageDialog.css @@ -1,44 +1,30 @@ -.cru-full-page { +.cru-dialog-full-page { position: fixed; z-index: 1030; left: 0; top: 0; right: 0; bottom: 0; - background-color: white; + background-color: var(--cru-background-color); padding-top: 56px; } -.cru-full-page-top-bar { +.cru-dialog-full-page-top-bar { height: 56px; position: absolute; top: 0; left: 0; right: 0; z-index: 1; - background-color: var(--cru-primary-color); + background-color: var(--cru-theme-color); display: flex; align-items: center; } -.cru-full-page-content-container { +.cru-dialog-full-page-content-container { overflow: scroll; } -.cru-full-page-back-button { - color: var(--cru-primary-t-color); -} - -.cru-full-page-enter { - transform: translate(100%, 0); -} - -.cru-full-page-enter-active { - transform: none; - transition: transform 0.3s; -} - -.cru-full-page-exit-active { - transition: transform 0.3s; - transform: translate(100%, 0); +.cru-dialog-full-page-back-button { + margin-left: 0.5em; } diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx index cba57e21..d18bcf73 100644 --- a/FrontEnd/src/components/dialog/FullPageDialog.tsx +++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx @@ -1,53 +1,51 @@ -import * as React from "react"; +import { ReactNode } from "react"; import { createPortal } from "react-dom"; -import classnames from "classnames"; -import { CSSTransition } from "react-transition-group"; +import classNames from "classnames"; + +import { ThemeColor, UiLogicError } from "../common"; +import { IconButton } from "../button"; + +import { useCloseDialog } from "./DialogProvider"; import "./FullPageDialog.css"; -import IconButton from "../button/IconButton"; -export interface FullPageDialogProps { - show: boolean; - onBack: () => void; +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new UiLogicError(); +} +const portalElement = optionalPortalElement; + +interface FullPageDialogProps { + color?: ThemeColor; contentContainerClassName?: string; - children: React.ReactNode; + children: ReactNode; } -const FullPageDialog: React.FC = ({ - show, - onBack, +export default function FullPageDialog({ + color, children, contentContainerClassName, -}) => { +}: FullPageDialogProps) { + const closeDialog = useCloseDialog(); + return createPortal( - -
-
- -
-
- {children} -
+
+
+ +
+
+ {children}
- , - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById("portal")!, +
, + portalElement, ); -}; - -export default FullPageDialog; +} diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts index 6bcce96c..42c134ef 100644 --- a/FrontEnd/src/components/hooks/responsive.ts +++ b/FrontEnd/src/components/hooks/responsive.ts @@ -2,6 +2,6 @@ import { useMediaQuery } from "react-responsive"; import { breakpoints } from "../breakpoints"; -export function useMobile(): boolean { - return useMediaQuery({ maxWidth: breakpoints.sm }); +export function useMobile(onChange?: (mobile: boolean) => void): boolean { + return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange); } diff --git a/FrontEnd/src/pages/timeline/CollapseButton.tsx b/FrontEnd/src/pages/timeline/CollapseButton.tsx deleted file mode 100644 index 1c4fa2ba..00000000 --- a/FrontEnd/src/pages/timeline/CollapseButton.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { CSSProperties } from "react"; - -import IconButton from "~src/components/button/IconButton"; - -export default function CollapseButton({ - collapse, - onClick, - className, - style, -}: { - collapse: boolean; - onClick: () => void; - className?: string; - style?: CSSProperties; -}) { - return ( - - ); -} diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx index caf4f502..4fe5c521 100644 --- a/FrontEnd/src/pages/timeline/Timeline.tsx +++ b/FrontEnd/src/pages/timeline/Timeline.tsx @@ -19,7 +19,7 @@ import { useScrollToBottom } from "~src/components/hooks"; import TimelinePostList from "./TimelinePostList"; import TimelinePostEdit from "./TimelinePostCreateView"; -import TimelineCard from "./TimelineCard"; +import TimelineInfoCard from "./TimelineInfoCard"; import "./Timeline.css"; @@ -159,7 +159,7 @@ export function Timeline(props: TimelineProps) { return (
{timeline && ( - void; -} - -export default function TimelineCard(props: TimelinePageCardProps) { - const { timeline, connectionStatus, onReload } = props; - - const user = useUser(); - - const [collapse, setCollapse] = useState(true); - const toggleCollapse = (): void => { - setCollapse((o) => !o); - }; - - const isMobile = useMobile(); - - const { controller, createDialogSwitch } = useDialog({ - member: ( - - - - ), - property: ( - - ), - delete: , - }); - - const content = ( -
-

- {timeline.title} - {timeline.nameV2} -

-
- - - {timeline.owner.nickname} - - - @{timeline.owner.username} - -
-

{timeline.description}

-
- {user && ( - { - getHttpBookmarkClient() - [timeline.isBookmark ? "delete" : "post"]( - user.username, - timeline.owner.username, - timeline.nameV2, - ) - .then(onReload, () => { - pushAlert({ - message: timeline.isBookmark - ? "timeline.removeBookmarkFail" - : "timeline.addBookmarkFail", - color: "danger", - }); - }); - }} - /> - )} - - {timeline.manageable && ( - - - - )} -
-
- ); - - return ( - -
- - -
- {isMobile ? ( - - {content} - - ) : ( -
{content}
- )} - -
- ); -} diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css new file mode 100644 index 00000000..29e59b62 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css @@ -0,0 +1,63 @@ +.timeline-card { + position: fixed; + z-index: 1029; + top: 56px; + right: 0; + margin: 0.5em; + padding: 0.5em; + box-shadow: var(--timeline-card-shadow); +} + +@media (min-width: 576px) { + .timeline-card-expand { + min-width: 400px; + } +} + +.timeline-card-title { + display: inline-block; + vertical-align: middle; + color: var(--cru-text-major-color); + margin: 0.5em 1em; +} + +.timeline-card-title-name { + margin-inline-start: 1em; + color: var(--cru-text-minor-color); +} + +.timeline-card-user { + display: flex; + align-items: center; + margin: 0 1em 0.5em; +} + +.timeline-card-user-avatar { + width: 2em; + height: 2em; + border-radius: 50%; +} + +.timeline-card-user-nickname { + margin-inline: 0.6em; +} + +.timeline-card-description { + margin: 0 1em 0.5em; +} + +.timeline-card-top-right-area { + float: right; + display: flex; + align-items: center; + margin: 0 1em; +} + +.timeline-card-buttons { + display: flex; + justify-content: end; +} + +.timeline-card-button { + margin: 0 0.2em; +} \ No newline at end of file diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..b1310be9 --- /dev/null +++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { HubConnectionState } from "@microsoft/signalr"; + +import { useUser } from "~src/services/user"; + +import { HttpTimelineInfo } from "~src/http/timeline"; +import { getHttpBookmarkClient } from "~src/http/bookmark"; + +import { pushAlert } from "~src/components/alert"; +import { useMobile } from "~src/components/hooks"; +import { IconButton } from "~src/components/button"; +import { + Dialog, + FullPageDialog, + DialogProvider, + useDialog, +} from "~src/components/dialog"; +import UserAvatar from "~src/components/user/UserAvatar"; +import PopupMenu from "~src/components/menu/PopupMenu"; +import Card from "~src/components/Card"; + +import TimelineDeleteDialog from "./TimelineDeleteDialog"; +import ConnectionStatusBadge from "./ConnectionStatusBadge"; +import TimelineMember from "./TimelineMember"; +import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; + +import "./TimelineInfoCard.css"; + +function CollapseButton({ + collapse, + onClick, + className, +}: { + collapse: boolean; + onClick: () => void; + className?: string; +}) { + return ( + + ); +} + +interface TimelineInfoCardProps { + timeline: HttpTimelineInfo; + connectionStatus: HubConnectionState; + onReload: () => void; +} + +function TimelineInfoContent({ + timeline, + onReload, +}: Omit) { + const user = useUser(); + + const { controller, createDialogSwitch } = useDialog({ + member: ( + + + + ), + property: ( + + ), + delete: , + }); + + return ( +
+

+ {timeline.title} + {timeline.nameV2} +

+
+ + + {timeline.owner.nickname} + + + @{timeline.owner.username} + +
+

{timeline.description}

+
+ {user && ( + { + getHttpBookmarkClient() + [timeline.isBookmark ? "delete" : "post"]( + user.username, + timeline.owner.username, + timeline.nameV2, + ) + .then(onReload, () => { + pushAlert({ + message: timeline.isBookmark + ? "timeline.removeBookmarkFail" + : "timeline.addBookmarkFail", + color: "danger", + }); + }); + }} + /> + )} + + {timeline.manageable && ( + + + + )} +
+ +
+ ); +} + +export default function TimelineInfoCard(props: TimelineInfoCardProps) { + const { timeline, connectionStatus, onReload } = props; + + const [collapse, setCollapse] = useState(true); + + const isMobile = useMobile((mobile) => { + if (!mobile) { + switchDialog(null); + } else { + setCollapse(true); + } + }); + + const { controller, switchDialog } = useDialog({ + "full-page": ( + + + + ), + }); + + return ( + +
+ + { + const open = collapse; + setCollapse(!open); + if (isMobile && open) { + switchDialog("full-page"); + } + }} + /> +
+ {!collapse && !isMobile && ( + + )} + +
+ ); +} -- cgit v1.2.3 From 754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 14 Sep 2023 18:58:44 +0800 Subject: Fix mobile post create. --- FrontEnd/src/components/common.ts | 3 + FrontEnd/src/components/hooks/index.ts | 1 + FrontEnd/src/components/hooks/useWindowLeave.ts | 22 ++ FrontEnd/src/pages/timeline/edit/ImagePostEdit.css | 2 +- FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx | 6 +- .../src/pages/timeline/edit/MarkdownPostEdit.tsx | 369 ++++++++++----------- .../src/pages/timeline/edit/PlainTextPostEdit.css | 7 +- .../src/pages/timeline/edit/PlainTextPostEdit.tsx | 11 +- .../pages/timeline/edit/TimelinePostCreateView.tsx | 223 ++++++------- FrontEnd/src/services/TimelinePostBuilder.ts | 128 ------- FrontEnd/src/utilities/array.ts | 41 +++ 11 files changed, 366 insertions(+), 447 deletions(-) create mode 100644 FrontEnd/src/components/hooks/useWindowLeave.ts delete mode 100644 FrontEnd/src/services/TimelinePostBuilder.ts create mode 100644 FrontEnd/src/utilities/array.ts (limited to 'FrontEnd/src/components/hooks') diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts index b9b55f9b..a6c3e705 100644 --- a/FrontEnd/src/components/common.ts +++ b/FrontEnd/src/components/common.ts @@ -17,3 +17,6 @@ export type ClickableColor = ThemeColor | "grayscale" | "light" | "minor"; export { breakpoints } from "./breakpoints"; export * as geometry from "~src/utilities/geometry"; + +export * as array from "~src/utilities/array" + diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts index 3c9859bc..771b0e2a 100644 --- a/FrontEnd/src/components/hooks/index.ts +++ b/FrontEnd/src/components/hooks/index.ts @@ -1,3 +1,4 @@ export { useMobile } from "./responsive"; export { default as useClickOutside } from "./useClickOutside"; export { default as useScrollToBottom } from "./useScrollToBottom"; +export { default as useWindowLeave } from "./useWindowLeave"; diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts new file mode 100644 index 00000000..08777e30 --- /dev/null +++ b/FrontEnd/src/components/hooks/useWindowLeave.ts @@ -0,0 +1,22 @@ +import { useEffect } from "react"; + +import { useC, Text } from "../common"; + +export default function useWindowLeave( + allow: boolean, + message: Text = "timeline.confirmLeave", +) { + const c = useC(); + + useEffect(() => { + if (!allow) { + window.onbeforeunload = () => { + return c(message); + }; + + return () => { + window.onbeforeunload = null; + }; + } + }, [allow, message]); +} diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css index 3d5e895c..df7a6af6 100644 --- a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css +++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css @@ -1,4 +1,4 @@ -.timeline-post-create-image { +.timeline-edit-image { max-width: 100px; max-height: 100px; } diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx index d25d04b4..4676e45a 100644 --- a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx +++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx @@ -3,7 +3,7 @@ import classNames from "classnames"; import BlobImage from "~/src/components/BlobImage"; interface TimelinePostEditImageProps { - file: File; + file: File | null; onChange: (file: File | null) => void; disabled: boolean; className?: string; @@ -14,7 +14,7 @@ export default function ImagePostEdit(props: TimelinePostEditImageProps) { return (
- {file && } + {file && }
); } diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx index d10d3f2d..0dfaf33e 100644 --- a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx +++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx @@ -1,216 +1,201 @@ -import * as React from "react"; +import { useEffect, useState } from "react"; import classnames from "classnames"; -import { useTranslation } from "react-i18next"; +import { marked } from "marked"; -import { - getHttpTimelineClient, - HttpTimelinePostInfo, -} from "~src/http/timeline"; +import { HttpTimelinePostPostRequestData } from "~src/http/timeline"; -import TimelinePostBuilder from "~src/services/TimelinePostBuilder"; +import base64 from "~src/utilities/base64"; -import FlatButton from "~src/components/button/FlatButton"; +import { array } from "~src/components/common"; import { TabPages } from "~src/components/tab"; -import ConfirmDialog from "~src/components/dialog/ConfirmDialog"; -import Spinner from "~src/components/Spinner"; -import IconButton from "~src/components/button/IconButton"; -import { DialogProvider, useDialog } from "~src/components/dialog"; +import { IconButton } from "~src/components/button"; +import BlobImage from "~src/components/BlobImage"; import "./MarkdownPostEdit.css"; -export interface MarkdownPostEditProps { - owner: string; - timeline: string; - onPosted: (post: HttpTimelinePostInfo) => void; - onPostError: () => void; - onClose: () => void; - className?: string; +class MarkedRenderer extends marked.Renderer { + constructor(public images: string[]) { + super(); + } + + // Custom image parser for indexed image link. + image(href: string | null, title: string | null, text: string): string { + if (href != null) { + const i = parseInt(href); + if (!isNaN(i) && i > 0 && i <= this.images.length) { + href = this.images[i - 1]; + } + } + + return this.image(href, title, text); + } } -const MarkdownPostEdit: React.FC = ({ - owner: ownerUsername, - timeline: timelineName, - onPosted, - onClose, - onPostError, - className, -}) => { - const { t } = useTranslation(); - - const [canLeave, setCanLeave] = React.useState(true); - - const [process, setProcess] = React.useState(false); - - const { controller, switchDialog } = useDialog({ - "leave-confirm": ( - - ), - }); - - const [text, _setText] = React.useState(""); - const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [], - ); - const [previewHtml, _setPreviewHtml] = React.useState(""); - - const _builder = React.useRef(null); - - const getBuilder = (): TimelinePostBuilder => { - if (_builder.current == null) { - const builder = new TimelinePostBuilder(() => { - setCanLeave(builder.isEmpty); - _setText(builder.text); - _setImages(builder.images); - _setPreviewHtml(builder.renderHtml()); - }); - _builder.current = builder; - } - return _builder.current; +function generateMarkedOptions(imageUrls: string[]): marked.MarkedOptions { + return { + mangle: false, + headerIds: false, + renderer: new MarkedRenderer(imageUrls), }; +} - const canSend = text.length > 0; +function renderHtml(text: string, imageUrls: string[]): string { + return marked.parse(text, generateMarkedOptions(imageUrls)); +} - React.useEffect(() => { - return () => { - getBuilder().dispose(); - }; - }, []); +async function build( + text: string, + images: File[], +): Promise { + return [ + { + contentType: "text/markdown", + data: await base64(text), + }, + ...(await Promise.all( + images.map(async (image) => { + const data = await base64(image); + return { contentType: image.type, data }; + }), + )), + ]; +} - React.useEffect(() => { - window.onbeforeunload = (): unknown => { - if (!canLeave) { - return t("timeline.confirmLeave"); - } - }; +export function useMarkdownEdit(disabled: boolean): { + hasContent: boolean; + clear: () => void; + build: () => Promise; + markdownEditProps: Omit; +} { + const [text, setText] = useState(""); + const [images, setImages] = useState([]); + + return { + hasContent: text !== "" || images.length !== 0, + clear: () => { + setText(""); + setImages([]); + }, + build: () => { + return build(text, images); + }, + markdownEditProps: { + disabled, + text, + images, + onTextChange: setText, + onImageAppend: (image) => setImages(array.copy_push(images, image)), + onImageMove: (o, n) => setImages(array.copy_move(images, o, n)), + onImageDelete: (i) => setImages(array.copy_delete(images, i)), + }, + }; +} + +function MarkdownPreview({ text, images }: { text: string; images: File[] }) { + const [html, setHtml] = useState(""); + + useEffect(() => { + const imageUrls = images.map((image) => URL.createObjectURL(image)); + + setHtml(renderHtml(text, imageUrls)); return () => { - window.onbeforeunload = null; + imageUrls.forEach((url) => URL.revokeObjectURL(url)); }; - }, [canLeave, t]); - - const send = async (): Promise => { - setProcess(true); - try { - const dataList = await getBuilder().build(); - const post = await getHttpTimelineClient().postPost( - ownerUsername, - timelineName, - { - dataList, - }, - ); - onPosted(post); - onClose(); - } catch (e) { - setProcess(false); - onPostError(); - } - }; + }, [text, images]); return ( - <> - - ) : ( -
- { - if (canLeave) { - onClose(); - } else { - switchDialog("leave-confirm"); +
+ ); +} + +interface MarkdownPostEditProps { + disabled: boolean; + text: string; + images: File[]; + onTextChange: (text: string) => void; + onImageAppend: (image: File) => void; + onImageMove: (oldIndex: number, newIndex: number) => void; + onImageDelete: (index: number) => void; + className?: string; +} + +export function MarkdownPostEdit({ + disabled, + text, + images, + onTextChange, + onImageAppend, + // onImageMove, + onImageDelete, + className, +}: MarkdownPostEditProps) { + return ( + { + onTextChange(event.currentTarget.value); + }} + /> + ), + }, + { + name: "images", + text: "image", + page: ( +
+ {images.map((image, index) => ( +
+ + { + onImageDelete(index); + }} + /> +
+ ))} + ) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + onImageAppend(files[0]); } }} + disabled={disabled} /> - {canSend && ( - void send()} /> - )}
- ) - } - pages={[ - { - name: "text", - text: "edit", - page: ( -