diff options
author | crupest <crupest@outlook.com> | 2023-09-14 18:58:44 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2023-09-15 21:39:52 +0800 |
commit | 754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5 (patch) | |
tree | 342a27eb79319a98c95838b4f88149196dbe0ed8 /FrontEnd | |
parent | 40b4871c3f7bfe04f332ae7fb687fd7d9ae34734 (diff) | |
download | timeline-754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5.tar.gz timeline-754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5.tar.bz2 timeline-754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5.zip |
Fix mobile post create.
Diffstat (limited to 'FrontEnd')
-rw-r--r-- | FrontEnd/src/components/common.ts | 3 | ||||
-rw-r--r-- | FrontEnd/src/components/hooks/index.ts | 1 | ||||
-rw-r--r-- | FrontEnd/src/components/hooks/useWindowLeave.ts | 22 | ||||
-rw-r--r-- | FrontEnd/src/pages/timeline/edit/ImagePostEdit.css | 2 | ||||
-rw-r--r-- | FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx | 6 | ||||
-rw-r--r-- | FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx | 369 | ||||
-rw-r--r-- | FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css | 7 | ||||
-rw-r--r-- | FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx | 11 | ||||
-rw-r--r-- | FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx | 223 | ||||
-rw-r--r-- | FrontEnd/src/services/TimelinePostBuilder.ts | 128 | ||||
-rw-r--r-- | FrontEnd/src/utilities/array.ts | 41 |
11 files changed, 366 insertions, 447 deletions
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 ( <div - className={classNames("timeline-post-create-edit-container", className)} + className={classNames("timeline-edit-image-container", className)} > <input type="file" @@ -30,7 +30,7 @@ export default function ImagePostEdit(props: TimelinePostEditImageProps) { }} className="mx-3 my-1" /> - {file && <BlobImage src={file} className="timeline-post-create-image" />} + {file && <BlobImage src={file} className="timeline-edit-image" />} </div> ); } 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<MarkdownPostEditProps> = ({ - owner: ownerUsername, - timeline: timelineName, - onPosted, - onClose, - onPostError, - className, -}) => { - const { t } = useTranslation(); - - const [canLeave, setCanLeave] = React.useState<boolean>(true); - - const [process, setProcess] = React.useState<boolean>(false); - - const { controller, switchDialog } = useDialog({ - "leave-confirm": ( - <ConfirmDialog - onConfirm={onClose} - title="timeline.dropDraft" - body="timeline.confirmLeave" - /> - ), - }); - - const [text, _setText] = React.useState<string>(""); - const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [], - ); - const [previewHtml, _setPreviewHtml] = React.useState<string>(""); - - const _builder = React.useRef<TimelinePostBuilder | null>(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<HttpTimelinePostPostRequestData[]> { + 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<HttpTimelinePostPostRequestData[]>; + markdownEditProps: Omit<MarkdownPostEditProps, "className">; +} { + const [text, setText] = useState<string>(""); + const [images, setImages] = useState<File[]>([]); + + 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<void> => { - 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 ( - <> - <TabPages - className={className} - dense - actions={ - process ? ( - <Spinner /> - ) : ( - <div> - <IconButton - icon="x" - color="danger" - large - className="cru-align-middle me-2" - onClick={() => { - if (canLeave) { - onClose(); - } else { - switchDialog("leave-confirm"); + <div + className="timeline-edit-markdown-preview" + dangerouslySetInnerHTML={{ __html: html }} + /> + ); +} + +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 ( + <TabPages + className={className} + dense + pages={[ + { + name: "text", + text: "edit", + page: ( + <textarea + value={text} + disabled={disabled} + className="timeline-edit-markdown-text" + onChange={(event) => { + onTextChange(event.currentTarget.value); + }} + /> + ), + }, + { + name: "images", + text: "image", + page: ( + <div className="timeline-edit-markdown-images"> + {images.map((image, index) => ( + <div + key={image.name} + className="timeline-edit-markdown-image-container" + > + <BlobImage src={image} /> + <IconButton + icon="trash" + color="danger" + className={classnames( + "timeline-edit-markdown-image-delete", + process && "d-none", + )} + onClick={() => { + onImageDelete(index); + }} + /> + </div> + ))} + <input + type="file" + accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + onImageAppend(files[0]); } }} + disabled={disabled} /> - {canSend && ( - <FlatButton text="timeline.send" onClick={() => void send()} /> - )} </div> - ) - } - pages={[ - { - name: "text", - text: "edit", - page: ( - <textarea - value={text} - disabled={process} - className="timeline-post-create-markdown-edit-area cru-fill-parent" - onChange={(event) => { - getBuilder().setMarkdownText(event.currentTarget.value); - }} - /> - ), - }, - { - name: "images", - text: "image", - page: ( - <div className="timeline-markdown-post-edit-page"> - {images.map((image, index) => ( - <div - key={image.url} - className="timeline-markdown-post-edit-image-container" - > - <img - src={image.url} - className="timeline-markdown-post-edit-image" - /> - <IconButton - icon="trash" - color="danger" - className={classnames( - "timeline-markdown-post-edit-image-delete-button", - process && "d-none", - )} - onClick={() => { - getBuilder().deleteImage(index); - }} - /> - </div> - ))} - <input - type="file" - accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" - onChange={(event: React.ChangeEvent<HTMLInputElement>) => { - const { files } = event.currentTarget; - if (files != null && files.length !== 0) { - getBuilder().appendImage(files[0]); - } - }} - disabled={process} - /> - </div> - ), - }, - { - name: "preview", - text: "preview", - page: ( - <div - className="markdown-container timeline-markdown-post-edit-page" - dangerouslySetInnerHTML={{ __html: previewHtml }} - /> - ), - }, - ]} - /> - <DialogProvider controller={controller} /> - </> + ), + }, + { + name: "preview", + text: "preview", + page: <MarkdownPreview text={text} images={images} />, + }, + ]} + /> ); -}; - -export default MarkdownPostEdit; +} diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css index 0f2b9dbd..4a608304 100644 --- a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css +++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css @@ -1,4 +1,4 @@ -.timeline-post-create-edit-text { +.timeline-edit-plain-text-input { width: 100%; height: 100%; background-color: var(--cru-background-color); @@ -6,13 +6,14 @@ border: 1px solid var(--cru-text-major-color); padding: 0.5em; border-radius: 5px; + transition: border-color 0.5s; } -.timeline-post-create-edit-text:hover { +.timeline-edit-plain-text-input:hover { border-color: var(--cru-clickable-secondary-normal-color); } -.timeline-post-create-edit-text:focus { +.timeline-edit-plain-text-input:focus { border-color: var(--cru-clickable-secondary-normal-color); } diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx index 1bea3daf..7f3663b2 100644 --- a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx +++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx @@ -1,4 +1,6 @@ -import classNames from 'classnames' +import classNames from "classnames"; + +import "./PlainTextPostEdit.css"; interface TimelinePostEditTextProps { text: string; @@ -11,16 +13,17 @@ export default function TimelinePostEditText(props: TimelinePostEditTextProps) { const { text, disabled, onChange, className } = props; return ( - <div className={classNames("timeline-post-create-edit-container", className)}> + <div + className={classNames("timeline-edit-plain-text-container", className)} + > <textarea value={text} disabled={disabled} onChange={(event) => { onChange(event.target.value); }} - className={classNames("timeline-post-create-edit-text")} + className={classNames("timeline-edit-plain-text-input")} /> </div> ); } - diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx index 45d742f7..0de75ccd 100644 --- a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx +++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx @@ -1,5 +1,4 @@ -import { useState, useEffect, ChangeEventHandler } from "react"; -import { useTranslation } from "react-i18next"; +import { useState } from "react"; import classNames from "classnames"; import { UiLogicError } from "~src/common"; @@ -13,22 +12,20 @@ import { import base64 from "~src/utilities/base64"; +import { useC } from "~/src/components/common"; import { pushAlert } from "~src/components/alert"; -import LoadingButton from "~src/components/button/LoadingButton"; +import { IconButton, LoadingButton } from "~src/components/button"; import PopupMenu from "~src/components/menu/PopupMenu"; +import { useWindowLeave } from "~src/components/hooks"; + import TimelinePostCard from "../TimelinePostCard"; import TimelinePostContainer from "../TimelinePostContainer"; -import IconButton from "~src/components/button/IconButton"; - -import PlainTextPostEdit from './PlainTextPostEdit' -import MarkdownPostEdit from "./MarkdownPostEdit"; +import PlainTextPostEdit from "./PlainTextPostEdit"; +import ImagePostEdit from "./ImagePostEdit"; +import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit"; import "./TimelinePostCreateView.css"; - - - - type PostKind = "text" | "markdown" | "image"; const postKindIconMap: Record<PostKind, string> = { @@ -46,25 +43,30 @@ export interface TimelinePostEditProps { function TimelinePostEdit(props: TimelinePostEditProps) { const { timeline, className, onPosted } = props; - const { t } = useTranslation(); + const c = useC(); const [process, setProcess] = useState<boolean>(false); - const [kind, setKind] = useState<Exclude<PostKind, "markdown">>("text"); - const [showMarkdown, setShowMarkdown] = useState<boolean>(false); - - const [text, setText] = useState<string>(""); - const [image, setImage] = useState<File | null>(null); + const [kind, setKind] = useState<PostKind>("text"); const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`; + const [text, setText] = useState<string>( + () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "", + ); + const [image, setImage] = useState<File | null>(null); + const { + hasContent: mdHasContent, + build: mdBuild, + clear: mdClear, + markdownEditProps, + } = useMarkdownEdit(process); - useEffect(() => { - setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); - }, [draftTextLocalStorageKey]); + useWindowLeave(!mdHasContent && !image); const canSend = (kind === "text" && text.length !== 0) || - (kind === "image" && image != null); + (kind === "image" && image != null) || + (kind === "markdown" && mdHasContent); const onPostError = (): void => { pushAlert({ @@ -76,48 +78,59 @@ function TimelinePostEdit(props: TimelinePostEditProps) { const onSend = async (): Promise<void> => { setProcess(true); - let requestData: HttpTimelinePostPostRequestData; + let requestDataList: HttpTimelinePostPostRequestData[]; switch (kind) { case "text": - requestData = { - contentType: "text/plain", - data: await base64(text), - }; + requestDataList = [ + { + contentType: "text/plain", + data: await base64(text), + }, + ]; break; case "image": if (image == null) { - throw new UiLogicError( - "Content type is image but image blob is null.", - ); + throw new UiLogicError(); } - requestData = { - contentType: image.type, - data: await base64(image), - }; + requestDataList = [ + { + contentType: image.type, + data: await base64(image), + }, + ]; break; + case "markdown": + if (!mdHasContent) { + throw new UiLogicError(); + } + requestDataList = await mdBuild(); default: throw new UiLogicError("Unknown content type."); } - getHttpTimelineClient() - .postPost(timeline.owner.username, timeline.nameV2, { - dataList: [requestData], - }) - .then( - (data) => { - if (kind === "text") { - setText(""); - window.localStorage.removeItem(draftTextLocalStorageKey); - } - setProcess(false); - setKind("text"); - onPosted(data); - }, - () => { - setProcess(false); - onPostError(); + try { + const res = await getHttpTimelineClient().postPost( + timeline.owner.username, + timeline.nameV2, + { + dataList: requestDataList, }, ); + + if (kind === "text") { + setText(""); + window.localStorage.removeItem(draftTextLocalStorageKey); + } else if (kind === "image") { + setImage(null); + } else if (kind === "markdown") { + mdClear(); + } + onPosted(res); + } catch (e) { + onPostError(); + } finally { + setProcess(false); + } }; return ( @@ -125,73 +138,51 @@ function TimelinePostEdit(props: TimelinePostEditProps) { className={classNames(className, "timeline-post-create-container")} > <TimelinePostCard className="timeline-post-create-card"> - {showMarkdown ? ( - <MarkdownPostEdit - className="cru-fill-parent" - onClose={() => setShowMarkdown(false)} - owner={timeline.owner.username} - timeline={timeline.nameV2} - onPosted={onPosted} - onPostError={onPostError} - /> - ) : ( - <div className="timeline-post-create"> - <div className="timeline-post-create-edit-area"> - {(() => { - if (kind === "text") { - return ( - <PlainTextPostEdit - className="timeline-post-create-edit-text" - text={text} - disabled={process} - onChange={(text) => { - setText(text); - window.localStorage.setItem( - draftTextLocalStorageKey, - text, - ); - }} - /> - ); - } else if (kind === "image") { - return ( - <TimelinePostEditImage - onSelect={setImage} - disabled={process} - /> - ); - } - })()} - </div> - <div className="timeline-post-create-right-area"> - <PopupMenu - containerClassName="timeline-post-create-kind-select" - items={(["text", "image", "markdown"] as const).map((kind) => ({ - type: "button", - text: `timeline.post.type.${kind}`, - iconClassName: postKindIconMap[kind], - onClick: () => { - if (kind === "markdown") { - setShowMarkdown(true); - } else { - setKind(kind); - } - }, - }))} - > - <IconButton color="primary" icon={postKindIconMap[kind]} /> - </PopupMenu> - <LoadingButton - onClick={() => void onSend()} - color="primary" - disabled={!canSend} - loading={process} - > - {t("timeline.send")} - </LoadingButton> - </div> + <div className="timeline-post-create"> + <div className="timeline-post-create-edit-area"> + {kind === "text" && ( + <PlainTextPostEdit + text={text} + disabled={process} + onChange={(text) => { + setText(text); + window.localStorage.setItem(draftTextLocalStorageKey, text); + }} + /> + )} + {kind === "image" && ( + <ImagePostEdit + file={image} + onChange={setImage} + disabled={process} + /> + )} + {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />} + </div> + <div className="timeline-post-create-right-area"> + <PopupMenu + containerClassName="timeline-post-create-kind-select" + items={(["text", "image", "markdown"] as const).map((kind) => ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconMap[kind], + onClick: () => { + setKind(kind); + }, + }))} + > + <IconButton color="primary" icon={postKindIconMap[kind]} /> + </PopupMenu> + <LoadingButton + onClick={() => void onSend()} + color="primary" + disabled={!canSend} + loading={process} + > + {c("timeline.send")} + </LoadingButton> </div> - )} + </div> </TimelinePostCard> </TimelinePostContainer> ); diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts deleted file mode 100644 index 919d4f55..00000000 --- a/FrontEnd/src/services/TimelinePostBuilder.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { marked } from "marked"; - -import { UiLogicError } from "~src/common"; - -import base64 from "~src/utilities/base64"; - -import { HttpTimelinePostPostRequest } from "~src/http/timeline"; - -class TimelinePostMarkedRenderer extends marked.Renderer { - constructor(private _images: { file: File; url: string }[]) { - super(); - } - - 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].url; - } - } - return this.image(href, title, text); - } -} - -export default class TimelinePostBuilder { - private _onChange: () => void; - private _text = ""; - private _images: { file: File; url: string }[] = []; - private _markedOptions: marked.MarkedOptions; - - constructor(onChange: () => void) { - this._onChange = onChange; - this._markedOptions = { - renderer: new TimelinePostMarkedRenderer(this._images), - }; - } - - setMarkdownText(text: string): void { - this._text = text; - this._onChange(); - } - - appendImage(file: File): void { - this._images = this._images.slice(); - this._images.push({ - file, - url: URL.createObjectURL(file), - }); - this._onChange(); - } - - moveImage(oldIndex: number, newIndex: number): void { - if (oldIndex < 0 || oldIndex >= this._images.length) { - throw new UiLogicError("Old index out of range."); - } - - if (newIndex < 0) { - newIndex = 0; - } - - if (newIndex >= this._images.length) { - newIndex = this._images.length - 1; - } - - this._images = this._images.slice(); - - const [old] = this._images.splice(oldIndex, 1); - this._images.splice(newIndex, 0, old); - - this._onChange(); - } - - deleteImage(index: number): void { - if (index < 0 || index >= this._images.length) { - throw new UiLogicError("Old index out of range."); - } - - this._images = this._images.slice(); - - URL.revokeObjectURL(this._images[index].url); - this._images.splice(index, 1); - - this._onChange(); - } - - get text(): string { - return this._text; - } - - get images(): { file: File; url: string }[] { - return this._images; - } - - get isEmpty(): boolean { - return this._text.length === 0 && this._images.length === 0; - } - - renderHtml(): string { - return marked.parse(this._text, { - mangle: false, - headerIds: false, - }); - } - - dispose(): void { - for (const image of this._images) { - URL.revokeObjectURL(image.url); - } - this._images = []; - } - - async build(): Promise<HttpTimelinePostPostRequest["dataList"]> { - return [ - { - contentType: "text/markdown", - data: await base64(this._text), - }, - ...(await Promise.all( - this._images.map((image) => - base64(image.file).then((data) => ({ - contentType: image.file.type, - data, - })), - ), - )), - ]; - } -} diff --git a/FrontEnd/src/utilities/array.ts b/FrontEnd/src/utilities/array.ts new file mode 100644 index 00000000..838e8744 --- /dev/null +++ b/FrontEnd/src/utilities/array.ts @@ -0,0 +1,41 @@ +export function copy_move<T>( + array: T[], + oldIndex: number, + newIndex: number, +): T[] { + if (oldIndex < 0 || oldIndex >= array.length) { + throw new Error("Old index out of range."); + } + + if (newIndex < 0) { + newIndex = 0; + } + + if (newIndex >= array.length) { + newIndex = array.length - 1; + } + + const result = array.slice(); + const [element] = result.splice(oldIndex, 1); + result.splice(newIndex, 0, element); + + return result; +} + +export function copy_insert<T>(array: T[], index: number, element: T): T[] { + const result = array.slice(); + result.splice(index, 0, element); + return result; +} + +export function copy_push<T>(array: T[], element: T): T[] { + const result = array.slice(); + result.push(element); + return result; +} + +export function copy_delete<T>(array: T[], index: number): T[] { + const result = array.slice(); + result.splice(index, 1); + return array; +} |