diff options
author | crupest <crupest@outlook.com> | 2021-03-18 21:01:07 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2021-03-18 21:01:07 +0800 |
commit | 2157ccdb22266781de271b302ddd1538498fcdc8 (patch) | |
tree | 88701aff1e627f450087a30567b6e2aeebf4e89a /FrontEnd/src | |
parent | 893ff2a4faca437f36b7a200014fe1fbb8bdf126 (diff) | |
download | timeline-2157ccdb22266781de271b302ddd1538498fcdc8.tar.gz timeline-2157ccdb22266781de271b302ddd1538498fcdc8.tar.bz2 timeline-2157ccdb22266781de271b302ddd1538498fcdc8.zip |
...
Diffstat (limited to 'FrontEnd/src')
-rw-r--r-- | FrontEnd/src/app/index.sass | 5 | ||||
-rw-r--r-- | FrontEnd/src/app/services/TimelinePostBuilder.ts | 32 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/TabPages.tsx | 74 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/common.sass | 4 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx | 147 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx | 2 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx | 167 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/timeline-common.sass | 8 |
8 files changed, 269 insertions, 170 deletions
diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index 39dce693..60f274c2 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -95,3 +95,8 @@ textarea i
line-height: 1
+
+.markdown-container
+ img
+ max-height: 200px
+ max-width: 100%
diff --git a/FrontEnd/src/app/services/TimelinePostBuilder.ts b/FrontEnd/src/app/services/TimelinePostBuilder.ts index f6c8f881..92d8484b 100644 --- a/FrontEnd/src/app/services/TimelinePostBuilder.ts +++ b/FrontEnd/src/app/services/TimelinePostBuilder.ts @@ -1,8 +1,3 @@ -import { - escapeHtml, - replaceEntities, - unescapeMd, -} from "remarkable/lib/common/utils"; import { Remarkable } from "remarkable"; import { UiLogicError } from "@/common"; @@ -18,28 +13,16 @@ export default class TimelinePostBuilder { constructor(onChange: () => void) { this._onChange = onChange; + const oldImageRenderer = this._md.renderer.rules.image; this._md.renderer.rules.image = (( _t: TimelinePostBuilder ): Remarkable.Rule<Remarkable.ImageToken, string> => function (tokens, idx, options /*, env */) { const i = parseInt(tokens[idx].src); - const src = - ' src="' + - (isNaN(i) && i > 0 && i <= _t._images.length - ? escapeHtml(tokens[idx].src) - : _t._images[i - 1].url) + - '"'; - const title = tokens[idx].title - ? ' title="' + escapeHtml(replaceEntities(tokens[idx].title)) + '"' - : ""; - const alt = - ' alt="' + - (tokens[idx].alt - ? escapeHtml(replaceEntities(unescapeMd(tokens[idx].alt))) - : "") + - '"'; - const suffix = options?.xhtmlOut ? " /" : ""; - return "<img" + src + alt + title + suffix + ">"; + if (!isNaN(i) && i > 0 && i <= _t._images.length) { + tokens[idx].src = _t._images[i - 1].url; + } + return oldImageRenderer(tokens, idx, options); })(this); } @@ -49,6 +32,7 @@ export default class TimelinePostBuilder { } appendImage(file: File): void { + this._images = this._images.slice(); this._images.push({ file, url: URL.createObjectURL(file), @@ -69,6 +53,8 @@ export default class TimelinePostBuilder { newIndex = this._images.length - 1; } + this._images = this._images.slice(); + const [old] = this._images.splice(oldIndex, 1); this._images.splice(newIndex, 0, old); @@ -80,6 +66,8 @@ export default class TimelinePostBuilder { throw new UiLogicError("Old index out of range."); } + this._images = this._images.slice(); + URL.revokeObjectURL(this._images[index].url); this._images.splice(index, 1); diff --git a/FrontEnd/src/app/views/common/TabPages.tsx b/FrontEnd/src/app/views/common/TabPages.tsx new file mode 100644 index 00000000..424e769f --- /dev/null +++ b/FrontEnd/src/app/views/common/TabPages.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Nav } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +export interface TabPage { + id: string; + tabText: I18nText; + page: React.ReactNode; +} + +export interface TabPagesProps { + pages: TabPage[]; + actions?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + navClassName?: string; + navStyle?: React.CSSProperties; + pageContainerClassName?: string; + pageContainerStyle?: React.CSSProperties; +} + +const TabPages: React.FC<TabPagesProps> = ({ + pages, + actions, + className, + style, + navClassName, + navStyle, + pageContainerClassName, + pageContainerStyle, +}) => { + if (pages.length === 0) { + throw new UiLogicError("Page list can't be empty."); + } + + const { t } = useTranslation(); + + const [tab, setTab] = React.useState<string>(pages[0].id); + + const currentPage = pages.find((p) => p.id === tab); + + if (currentPage == null) { + throw new UiLogicError("Current tab value is bad."); + } + + return ( + <div className={className} style={style}> + <Nav variant="tabs" className={navClassName} style={navStyle}> + {pages.map((page) => ( + <Nav.Item key={page.id}> + <Nav.Link + active={tab === page.id} + onClick={() => { + setTab(page.id); + }} + > + {convertI18nText(page.tabText, t)} + </Nav.Link> + </Nav.Item> + ))} + {actions != null && ( + <div className="ml-auto cru-tab-pages-action-area">{actions}</div> + )} + </Nav> + <div className={pageContainerClassName} style={pageContainerStyle}> + {currentPage.page} + </div> + </div> + ); +}; + +export default TabPages; diff --git a/FrontEnd/src/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass index 0a30d995..f3022d19 100644 --- a/FrontEnd/src/app/views/common/common.sass +++ b/FrontEnd/src/app/views/common/common.sass @@ -92,3 +92,7 @@ .cru-menu-divider
border-top: 1px solid $gray-200
+
+.cru-tab-pages-action-area
+ display: flex
+ align-items: center
diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx index 079344e1..d43077b4 100644 --- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx +++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx @@ -1,24 +1,31 @@ import React from "react"; -import { Nav, Form } from "react-bootstrap"; +import { Form } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; +import TabPages from "../common/TabPages"; import TimelinePostBuilder from "@/services/TimelinePostBuilder"; export interface MarkdownPostEditProps { timeline: string; onPosted: (post: HttpTimelinePostInfo) => void; + onPostError: () => void; + onClose: () => void; + className?: string; + style?: React.CSSProperties; } const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ timeline: timelineName, onPosted, + onClose, + onPostError, + className, + style, }) => { const { t } = useTranslation(); - const [tab, setTab] = React.useState<"text" | "images" | "preview">("text"); - const [process, setProcess] = React.useState<boolean>(false); const [text, _setText] = React.useState<string>(""); @@ -48,68 +55,88 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ }, []); const send = async (): Promise<void> => { - const dataList = await getBuilder().build(); - const post = await getHttpTimelineClient().postPost(timelineName, { - dataList, - }); - onPosted(post); + setProcess(true); + try { + const dataList = await getBuilder().build(); + const post = await getHttpTimelineClient().postPost(timelineName, { + dataList, + }); + onPosted(post); + onClose(); + } catch (e) { + setProcess(false); + onPostError(); + } }; return ( - <div> - <Nav variant="tabs" className="my-2"> - <Nav.Item> - <Nav.Link - active={tab === "text"} - onClick={() => { - setTab("text"); - }} - > - {t("edit")} - </Nav.Link> - </Nav.Item> - <Nav.Item> - <Nav.Link - active={tab === "images"} - onClick={() => { - setTab("images"); - }} - > - {t("image")} - </Nav.Link> - </Nav.Item> - <Nav.Item> - <Nav.Link - active={tab === "preview"} - onClick={() => { - setTab("preview"); - }} - > - {t("preview")} - </Nav.Link> - </Nav.Item> - </Nav> - <div> - {(() => { - if (tab === "text") { - return ( - <Form.Control - as="textarea" - value={text} - disabled={process} - onChange={(event) => { - getBuilder().setMarkdownText(event.currentTarget.value); + <TabPages + className={className} + style={style} + pageContainerClassName="py-2" + actions={ + <> + <div className="flat-button text-danger mr-2" onClick={onClose}> + {t("operationDialog.cancel")} + </div> + <div className="flat-button text-primary" onClick={send}> + {t("timeline.send")} + </div> + </> + } + pages={[ + { + id: "text", + tabText: "edit", + page: ( + <Form.Control + as="textarea" + value={text} + disabled={process} + onChange={(event) => { + getBuilder().setMarkdownText(event.currentTarget.value); + }} + /> + ), + }, + { + id: "images", + tabText: "image", + page: ( + <div className="timeline-markdown-post-edit-page"> + {images.map((image) => ( + <img + key={image.url} + src={image.url} + className="timeline-markdown-post-edit-image" + /> + ))} + <Form.File + label={t("chooseImage")} + accept="image/*" + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + getBuilder().appendImage(files[0]); + } }} + disabled={process} /> - ); - } else if (tab === "images") { - return <div></div>; - } else { - return <div dangerouslySetInnerHTML={{ __html: previewHtml }} />; - } - })()} - </div> - </div> + </div> + ), + }, + { + id: "preview", + tabText: "preview", + page: ( + <div + className="markdown-container timeline-markdown-post-edit-page" + dangerouslySetInnerHTML={{ __html: previewHtml }} + /> + ), + }, + ]} + /> ); }; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx index d836d1db..58fae4c7 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx @@ -152,7 +152,7 @@ const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => { } return ( <div - className={className} + className={clsx(className, "markdown-container")} style={style} dangerouslySetInnerHTML={{ __html: markdownHtml, diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx index 025b41c0..a474d2f6 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx @@ -18,6 +18,7 @@ import { base64 } from "@/http/common"; import BlobImage from "../common/BlobImage"; import LoadingButton from "../common/LoadingButton"; import { PopupMenu } from "../common/Menu"; +import MarkdownPostEdit from "./MarkdownPostEdit"; interface TimelinePostEditTextProps { text: string; @@ -121,26 +122,21 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { const [process, setProcess] = React.useState<boolean>(false); - const [kind, setKind] = React.useState<PostKind>("text"); + const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text"); + const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false); const [text, setText] = React.useState<string>(""); - const [markdown, setMarkdown] = React.useState<string>(""); const [image, setImage] = React.useState<File | null>(null); const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`; - const draftMarkdownLocalStorageKey = `timeline.${timeline.name}.postDraft.markdown`; React.useEffect(() => { setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); - setMarkdown( - window.localStorage.getItem(draftMarkdownLocalStorageKey) ?? "" - ); - }, [draftTextLocalStorageKey, draftMarkdownLocalStorageKey]); + }, [draftTextLocalStorageKey]); const canSend = (kind === "text" && text.length !== 0) || - (kind === "image" && image != null) || - (kind === "markdown" && markdown.length !== 0); + (kind === "image" && image != null); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const containerRef = React.useRef<HTMLDivElement>(null!); @@ -160,6 +156,13 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { }; }); + const onPostError = (): void => { + pushAlert({ + type: "danger", + message: "timeline.sendPostFailed", + }); + }; + const onSend = async (): Promise<void> => { setProcess(true); @@ -171,12 +174,6 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { data: await base64(text), }; break; - case "markdown": - requestData = { - contentType: "text/markdown", - data: await base64(markdown), - }; - break; case "image": if (image == null) { throw new UiLogicError( @@ -201,20 +198,14 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { if (kind === "text") { setText(""); window.localStorage.removeItem(draftTextLocalStorageKey); - } else if (kind === "markdown") { - setMarkdown(""); - window.localStorage.removeItem(draftMarkdownLocalStorageKey); } setProcess(false); setKind("text"); onPosted(data); }, (_) => { - pushAlert({ - type: "danger", - message: "timeline.sendPostFailed", - }); setProcess(false); + onPostError(); } ); }; @@ -224,73 +215,75 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { ref={containerRef} className={clsx("container-fluid bg-light", className)} > - <Row> - <Col className="px-1 py-1"> - {(() => { - if (kind === "text") { - return ( - <TimelinePostEditText - className="w-100 h-100 timeline-post-edit" - text={text} - disabled={process} - onChange={(t) => { - setText(t); - window.localStorage.setItem(draftTextLocalStorageKey, t); - }} - /> - ); - } else if (kind === "image") { - return ( - <TimelinePostEditImage onSelect={setImage} disabled={process} /> - ); - } else if (kind === "markdown") { - return ( - <TimelinePostEditText - className="w-100 h-100 timeline-post-edit" - text={markdown} - disabled={process} - onChange={(t) => { - setMarkdown(t); - window.localStorage.setItem( - draftMarkdownLocalStorageKey, - t - ); - }} + {showMarkdown ? ( + <MarkdownPostEdit + className="w-100" + onClose={() => setShowMarkdown(false)} + timeline={timeline.name} + onPosted={onPosted} + onPostError={onPostError} + /> + ) : ( + <Row> + <Col className="px-1 py-1"> + {(() => { + if (kind === "text") { + return ( + <TimelinePostEditText + className="w-100 h-100 timeline-post-edit" + text={text} + disabled={process} + onChange={(t) => { + setText(t); + window.localStorage.setItem(draftTextLocalStorageKey, t); + }} + /> + ); + } else if (kind === "image") { + return ( + <TimelinePostEditImage + onSelect={setImage} + disabled={process} + /> + ); + } + })()} + </Col> + <Col xs="auto" className="align-self-end m-1"> + <div className="d-block text-center mt-1 mb-2"> + <PopupMenu + items={(["text", "image", "markdown"] as const).map((kind) => ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconClassNameMap[kind], + onClick: () => { + if (kind === "markdown") { + setShowMarkdown(true); + } else { + setKind(kind); + } + }, + }))} + > + <i + className={clsx( + postKindIconClassNameMap[kind], + "icon-button large" + )} /> - ); - } - })()} - </Col> - <Col xs="auto" className="align-self-end m-1"> - <div className="d-block text-center mt-1 mb-2"> - <PopupMenu - items={(["text", "image", "markdown"] as const).map((kind) => ({ - type: "button", - text: `timeline.post.type.${kind}`, - iconClassName: postKindIconClassNameMap[kind], - onClick: () => { - setKind(kind); - }, - }))} + </PopupMenu> + </div> + <LoadingButton + variant="primary" + onClick={onSend} + disabled={!canSend} + loading={process} > - <i - className={clsx( - postKindIconClassNameMap[kind], - "icon-button large" - )} - /> - </PopupMenu> - </div> - <LoadingButton - variant="primary" - onClick={onSend} - disabled={!canSend} - loading={process} - > - {t("timeline.send")} - </LoadingButton> - </Col> - </Row> + {t("timeline.send")} + </LoadingButton> + </Col> + </Row> + )} </div> ); }; diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass index 43c4d0f7..04318674 100644 --- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass +++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass @@ -170,3 +170,11 @@ $timeline-line-color-current: #36c2e6 top: 56px right: 0 margin: 0.5em + +.timeline-markdown-post-edit-page + overflow: scroll + max-height: 300px + +.timeline-markdown-post-edit-image + max-width: 100% + max-height: 200px |