aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-09-14 18:58:44 +0800
committercrupest <crupest@outlook.com>2023-09-15 21:39:52 +0800
commit754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5 (patch)
tree342a27eb79319a98c95838b4f88149196dbe0ed8
parent40b4871c3f7bfe04f332ae7fb687fd7d9ae34734 (diff)
downloadtimeline-754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5.tar.gz
timeline-754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5.tar.bz2
timeline-754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5.zip
Fix mobile post create.
-rw-r--r--FrontEnd/src/components/common.ts3
-rw-r--r--FrontEnd/src/components/hooks/index.ts1
-rw-r--r--FrontEnd/src/components/hooks/useWindowLeave.ts22
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.css2
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx6
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx369
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css7
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx11
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx223
-rw-r--r--FrontEnd/src/services/TimelinePostBuilder.ts128
-rw-r--r--FrontEnd/src/utilities/array.ts41
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;
+}