mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(oekaki): add drawing flow for /i/ (#1144)
* feat(oekaki): add drawing flow for /i/ * fix(oekaki): address review feedback * fix(oekaki): reset Tegaki edit sessions * fix(oekaki): block drawing during export * fix(oekaki): destroy Tegaki on preload errors * fix(oekaki): preserve drawing on export failure * fix(oekaki): unlock controls after export failure
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import OekakiDrawingControls from '../oekaki-drawing-controls';
|
||||
import { OEKAKI_MOBILE_PORTRAIT_MESSAGE, OEKAKI_WEB_DOWNLOAD_MESSAGE } from '../../../lib/oekaki/oekaki-copy';
|
||||
import type { UploadedFileResult } from '../../../hooks/use-file-upload';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
runtime: 'web' as 'web' | 'electron' | 'android',
|
||||
loadTegakiMock: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
flattenMock: vi.fn(),
|
||||
destroyMock: vi.fn(),
|
||||
onOpenImageLoadedMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||
getMediaHostingRuntime: () => testState.runtime,
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/oekaki/tegaki-loader', () => ({
|
||||
TEGAKI_DRAWING_FILE_NAME: 'tegaki.png',
|
||||
loadTegaki: testState.loadTegakiMock,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const OriginalImage = globalThis.Image;
|
||||
|
||||
interface MockTegakiOpenOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
saveReplay: boolean;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const setViewport = (width: number, height: number) => {
|
||||
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
|
||||
Object.defineProperty(window, 'innerHeight', { configurable: true, value: height });
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(max-width: 640px) and (orientation: portrait)' && width <= 640 && height > width,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
const createFinishedCanvas = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
Object.defineProperty(canvas, 'toBlob', {
|
||||
configurable: true,
|
||||
value: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })),
|
||||
});
|
||||
return canvas;
|
||||
};
|
||||
|
||||
class MockImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.onload?.());
|
||||
}
|
||||
}
|
||||
|
||||
const renderControls = async ({
|
||||
uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue(null),
|
||||
onClearUploadedUrl = vi.fn(),
|
||||
}: {
|
||||
uploadFile?: (file: File) => Promise<UploadedFileResult | null>;
|
||||
onClearUploadedUrl?: (url: string) => void;
|
||||
} = {}) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(OekakiDrawingControls, { uploadFile, onClearUploadedUrl }));
|
||||
});
|
||||
return { uploadFile, onClearUploadedUrl };
|
||||
};
|
||||
|
||||
const getButton = (label: string): HTMLButtonElement => {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === label);
|
||||
if (!(button instanceof HTMLButtonElement)) {
|
||||
throw new Error(`Button ${label} not found`);
|
||||
}
|
||||
return button;
|
||||
};
|
||||
|
||||
const clickButton = async (label: string) => {
|
||||
const button = getButton(label);
|
||||
await act(async () => {
|
||||
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
};
|
||||
|
||||
const triggerTegakiDone = async () => {
|
||||
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions | undefined;
|
||||
if (!openOptions) {
|
||||
throw new Error('Tegaki open options not captured');
|
||||
}
|
||||
await act(async () => {
|
||||
openOptions.onDone();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
};
|
||||
|
||||
describe('OekakiDrawingControls', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.runtime = 'web';
|
||||
testState.openMock.mockReset();
|
||||
testState.flattenMock.mockReset();
|
||||
testState.destroyMock.mockReset();
|
||||
testState.onOpenImageLoadedMock.mockReset();
|
||||
testState.flattenMock.mockReturnValue(createFinishedCanvas());
|
||||
const tegaki = {
|
||||
bg: null as HTMLElement | null,
|
||||
open: testState.openMock,
|
||||
flatten: testState.flattenMock,
|
||||
destroy: testState.destroyMock,
|
||||
onOpenImageLoaded: testState.onOpenImageLoadedMock,
|
||||
};
|
||||
testState.openMock.mockImplementation(() => {
|
||||
tegaki.bg = document.createElement('div');
|
||||
});
|
||||
testState.destroyMock.mockImplementation(() => {
|
||||
tegaki.bg = null;
|
||||
});
|
||||
testState.loadTegakiMock.mockResolvedValue(tegaki);
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
configurable: true,
|
||||
value: MockImage,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'alert', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'confirm', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => true),
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 'blob:tegaki'),
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
|
||||
setViewport(1024, 768);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
Object.defineProperty(globalThis, 'Image', {
|
||||
configurable: true,
|
||||
value: OriginalImage,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('alerts instead of opening Tegaki on a portrait phone viewport', async () => {
|
||||
setViewport(390, 844);
|
||||
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
|
||||
expect(globalThis.alert).toHaveBeenCalledWith(OEKAKI_MOBILE_PORTRAIT_MESSAGE);
|
||||
expect(testState.loadTegakiMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens Tegaki on a landscape phone viewport', async () => {
|
||||
setViewport(844, 390);
|
||||
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
|
||||
expect(globalThis.alert).not.toHaveBeenCalled();
|
||||
expect(testState.loadTegakiMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.openMock).toHaveBeenCalledWith(expect.objectContaining({ width: 400, height: 400, saveReplay: true }));
|
||||
});
|
||||
|
||||
it('keeps Draw disabled while Tegaki is open', async () => {
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
|
||||
expect(getButton('Draw').disabled).toBe(true);
|
||||
await clickButton('Draw');
|
||||
expect(testState.loadTegakiMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions;
|
||||
await act(async () => {
|
||||
openOptions.onCancel();
|
||||
});
|
||||
|
||||
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
|
||||
expect(getButton('Draw').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('downloads the web drawing only after confirmation', async () => {
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
await triggerTegakiDone();
|
||||
|
||||
expect(globalThis.confirm).toHaveBeenCalledWith(OEKAKI_WEB_DOWNLOAD_MESSAGE);
|
||||
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps Draw disabled while a finished drawing is still exporting', async () => {
|
||||
let finishExport: BlobCallback | null = null;
|
||||
const canvas = document.createElement('canvas');
|
||||
Object.defineProperty(canvas, 'toBlob', {
|
||||
configurable: true,
|
||||
value: (callback: BlobCallback) => {
|
||||
finishExport = callback;
|
||||
},
|
||||
});
|
||||
testState.flattenMock.mockReturnValue(canvas);
|
||||
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions;
|
||||
await act(async () => {
|
||||
openOptions.onDone();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getButton('Draw').disabled).toBe(true);
|
||||
expect(testState.openMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
finishExport?.(new Blob(['png'], { type: 'image/png' }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(getButton('Edit').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('unlocks the controls when exporting the finished drawing fails', async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
Object.defineProperty(canvas, 'toBlob', {
|
||||
configurable: true,
|
||||
value: (callback: BlobCallback) => callback(null),
|
||||
});
|
||||
testState.flattenMock.mockReturnValue(canvas);
|
||||
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions;
|
||||
await act(async () => {
|
||||
openOptions.onDone();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('Could not export drawing');
|
||||
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
|
||||
expect(getButton('Draw').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not download the web drawing when confirmation is cancelled', async () => {
|
||||
Object.defineProperty(globalThis, 'confirm', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
});
|
||||
|
||||
await renderControls();
|
||||
await clickButton('Draw');
|
||||
await triggerTegakiDone();
|
||||
|
||||
expect(globalThis.confirm).toHaveBeenCalledWith(OEKAKI_WEB_DOWNLOAD_MESSAGE);
|
||||
expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled();
|
||||
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears the uploaded drawing URL when Clear is clicked', async () => {
|
||||
testState.runtime = 'electron';
|
||||
const uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue({
|
||||
url: 'https://files.example/tegaki.png',
|
||||
fileName: 'tegaki.png',
|
||||
});
|
||||
const onClearUploadedUrl = vi.fn();
|
||||
|
||||
await renderControls({ uploadFile, onClearUploadedUrl });
|
||||
await clickButton('Draw');
|
||||
await triggerTegakiDone();
|
||||
await clickButton('Clear');
|
||||
|
||||
expect(onClearUploadedUrl).toHaveBeenCalledWith('https://files.example/tegaki.png');
|
||||
});
|
||||
|
||||
it('clears a stale uploaded URL when re-uploading an edited drawing fails', async () => {
|
||||
testState.runtime = 'electron';
|
||||
const uploadFile = vi
|
||||
.fn<(file: File) => Promise<UploadedFileResult | null>>()
|
||||
.mockResolvedValueOnce({ url: 'https://files.example/first.png', fileName: 'tegaki.png' })
|
||||
.mockResolvedValueOnce(null);
|
||||
const onClearUploadedUrl = vi.fn();
|
||||
|
||||
await renderControls({ uploadFile, onClearUploadedUrl });
|
||||
await clickButton('Draw');
|
||||
await triggerTegakiDone();
|
||||
await clickButton('Edit');
|
||||
await triggerTegakiDone();
|
||||
|
||||
expect(uploadFile).toHaveBeenCalledTimes(2);
|
||||
expect(onClearUploadedUrl).toHaveBeenCalledWith('https://files.example/first.png');
|
||||
});
|
||||
|
||||
it('starts edited drawings from a fresh Tegaki session with the saved image loaded', async () => {
|
||||
testState.runtime = 'electron';
|
||||
const uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue({
|
||||
url: 'https://files.example/tegaki.png',
|
||||
fileName: 'tegaki.png',
|
||||
});
|
||||
|
||||
await renderControls({ uploadFile });
|
||||
await clickButton('Draw');
|
||||
await triggerTegakiDone();
|
||||
await clickButton('Edit');
|
||||
|
||||
expect(testState.openMock).toHaveBeenCalledTimes(2);
|
||||
expect(testState.destroyMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.onOpenImageLoadedMock).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(File));
|
||||
});
|
||||
|
||||
it('destroys Tegaki when an edited drawing cannot be loaded', async () => {
|
||||
testState.runtime = 'electron';
|
||||
const uploadFile = vi.fn<(file: File) => Promise<UploadedFileResult | null>>().mockResolvedValue({
|
||||
url: 'https://files.example/tegaki.png',
|
||||
fileName: 'tegaki.png',
|
||||
});
|
||||
|
||||
await renderControls({ uploadFile });
|
||||
await clickButton('Draw');
|
||||
await triggerTegakiDone();
|
||||
testState.onOpenImageLoadedMock.mockImplementationOnce(() => {
|
||||
throw new Error('Could not restore drawing');
|
||||
});
|
||||
await clickButton('Edit');
|
||||
|
||||
expect(testState.destroyMock).toHaveBeenCalledTimes(2);
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('Could not restore drawing');
|
||||
expect(getButton('Edit').disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './oekaki-drawing-controls';
|
||||
@@ -0,0 +1,33 @@
|
||||
.controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 10pt;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.controls input.sizeInput {
|
||||
text-align: center;
|
||||
width: 30px !important;
|
||||
}
|
||||
|
||||
.replayLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.replayLabel input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controls button {
|
||||
position: static !important;
|
||||
margin-left: 0 !important;
|
||||
filter: var(--filter80);
|
||||
cursor: pointer;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { getMediaHostingRuntime } from '../../lib/media-hosting/show-upload-controls';
|
||||
import { loadTegaki, TEGAKI_DRAWING_FILE_NAME, type TegakiGlobal } from '../../lib/oekaki/tegaki-loader';
|
||||
import { OEKAKI_MOBILE_PORTRAIT_MESSAGE, OEKAKI_WEB_DOWNLOAD_MESSAGE } from '../../lib/oekaki/oekaki-copy';
|
||||
import type { UploadedFileResult } from '../../hooks/use-file-upload';
|
||||
import styles from './oekaki-drawing-controls.module.css';
|
||||
|
||||
const DEFAULT_DIMENSION = '400';
|
||||
const MIN_DIMENSION = 1;
|
||||
const MAX_DIMENSION = 2000;
|
||||
const PNG_MIME_TYPE = 'image/png';
|
||||
|
||||
interface OekakiDrawingControlsProps {
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
uploadFile: (file: File) => Promise<UploadedFileResult | null>;
|
||||
onClearUploadedUrl: (url: string) => void;
|
||||
}
|
||||
|
||||
const parseDimension = (value: string): number => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed)) return Number.parseInt(DEFAULT_DIMENSION, 10);
|
||||
return Math.min(MAX_DIMENSION, Math.max(MIN_DIMENSION, parsed));
|
||||
};
|
||||
|
||||
const makeDrawingFile = (blob: Blob): File => new File([blob], TEGAKI_DRAWING_FILE_NAME, { type: PNG_MIME_TYPE });
|
||||
|
||||
const isPhonePortraitViewport = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (typeof window.matchMedia === 'function') {
|
||||
return window.matchMedia('(max-width: 640px) and (orientation: portrait)').matches;
|
||||
}
|
||||
return window.innerWidth <= 640 && window.innerHeight > window.innerWidth;
|
||||
};
|
||||
|
||||
const canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
|
||||
new Promise((resolve, reject) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Could not export drawing'));
|
||||
}, PNG_MIME_TYPE);
|
||||
});
|
||||
|
||||
const downloadDrawing = (file: File): void => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = TEGAKI_DRAWING_FILE_NAME;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
const loadDrawingImage = (file: File | null): Promise<HTMLImageElement | null> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Could not load drawing'));
|
||||
};
|
||||
image.src = url;
|
||||
});
|
||||
|
||||
const destroyTegaki = (tegaki: TegakiGlobal): void => {
|
||||
if (tegaki.bg && typeof tegaki.destroy === 'function') {
|
||||
tegaki.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const OekakiDrawingControls = ({ disabled = false, className, uploadFile, onClearUploadedUrl }: OekakiDrawingControlsProps) => {
|
||||
const [width, setWidth] = useState(DEFAULT_DIMENSION);
|
||||
const [height, setHeight] = useState(DEFAULT_DIMENSION);
|
||||
const [saveReplay, setSaveReplay] = useState(true);
|
||||
const [drawingFile, setDrawingFile] = useState<File | null>(null);
|
||||
const [isOpening, setIsOpening] = useState(false);
|
||||
const [isTegakiOpen, setIsTegakiOpen] = useState(false);
|
||||
const [isUploadingDrawing, setIsUploadingDrawing] = useState(false);
|
||||
const uploadedDrawingUrlRef = useRef<string | null>(null);
|
||||
const tegakiSessionOpenRef = useRef(false);
|
||||
const runtime = getMediaHostingRuntime();
|
||||
const isBusy = disabled || isOpening || isTegakiOpen || isUploadingDrawing;
|
||||
const hasDrawing = drawingFile !== null;
|
||||
|
||||
const closeTegakiSession = () => {
|
||||
tegakiSessionOpenRef.current = false;
|
||||
setIsTegakiOpen(false);
|
||||
};
|
||||
|
||||
const handleDrawingFile = async (file: File) => {
|
||||
const previousUploadedUrl = uploadedDrawingUrlRef.current;
|
||||
setDrawingFile(file);
|
||||
|
||||
if (runtime === 'web') {
|
||||
uploadedDrawingUrlRef.current = null;
|
||||
if (previousUploadedUrl) {
|
||||
onClearUploadedUrl(previousUploadedUrl);
|
||||
}
|
||||
if (window.confirm(OEKAKI_WEB_DOWNLOAD_MESSAGE)) {
|
||||
downloadDrawing(file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingDrawing(true);
|
||||
try {
|
||||
const result = await uploadFile(file);
|
||||
if (result?.url) {
|
||||
uploadedDrawingUrlRef.current = result.url;
|
||||
return;
|
||||
}
|
||||
uploadedDrawingUrlRef.current = null;
|
||||
if (previousUploadedUrl) {
|
||||
onClearUploadedUrl(previousUploadedUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
uploadedDrawingUrlRef.current = null;
|
||||
if (previousUploadedUrl) {
|
||||
onClearUploadedUrl(previousUploadedUrl);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
setIsUploadingDrawing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openTegaki = async () => {
|
||||
if (isBusy || tegakiSessionOpenRef.current) return;
|
||||
if (isPhonePortraitViewport()) {
|
||||
window.alert(OEKAKI_MOBILE_PORTRAIT_MESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpening(true);
|
||||
try {
|
||||
const [tegaki, existingImage] = await Promise.all([loadTegaki(), loadDrawingImage(drawingFile)]);
|
||||
destroyTegaki(tegaki);
|
||||
tegakiSessionOpenRef.current = true;
|
||||
setIsTegakiOpen(true);
|
||||
tegaki.open({
|
||||
width: parseDimension(width),
|
||||
height: parseDimension(height),
|
||||
saveReplay,
|
||||
onDone: () => {
|
||||
const canvas = tegaki.flatten();
|
||||
setIsUploadingDrawing(true);
|
||||
void canvasToBlob(canvas)
|
||||
.then(makeDrawingFile)
|
||||
.then(async (file) => {
|
||||
destroyTegaki(tegaki);
|
||||
closeTegakiSession();
|
||||
await handleDrawingFile(file);
|
||||
})
|
||||
.catch((error) => {
|
||||
destroyTegaki(tegaki);
|
||||
closeTegakiSession();
|
||||
window.alert(error instanceof Error ? error.message : String(error));
|
||||
})
|
||||
.finally(() => {
|
||||
setIsUploadingDrawing(false);
|
||||
});
|
||||
},
|
||||
onCancel: () => {
|
||||
destroyTegaki(tegaki);
|
||||
closeTegakiSession();
|
||||
},
|
||||
});
|
||||
if (existingImage && typeof tegaki.onOpenImageLoaded === 'function') {
|
||||
try {
|
||||
tegaki.onOpenImageLoaded.call(existingImage);
|
||||
} catch (error) {
|
||||
destroyTegaki(tegaki);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
closeTegakiSession();
|
||||
window.alert(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsOpening(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearDrawing = () => {
|
||||
const uploadedUrl = uploadedDrawingUrlRef.current;
|
||||
setDrawingFile(null);
|
||||
uploadedDrawingUrlRef.current = null;
|
||||
if (uploadedUrl) {
|
||||
onClearUploadedUrl(uploadedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.controls} ${className ?? ''}`}>
|
||||
<span>Size</span>
|
||||
<input
|
||||
className={styles.sizeInput}
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
aria-label='Oekaki width'
|
||||
value={width}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setWidth(event.target.value)}
|
||||
/>
|
||||
<span>×</span>
|
||||
<input
|
||||
className={styles.sizeInput}
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
aria-label='Oekaki height'
|
||||
value={height}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setHeight(event.target.value)}
|
||||
/>
|
||||
<label className={styles.replayLabel}>
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label='Replay drawing'
|
||||
checked={saveReplay}
|
||||
disabled={isBusy || hasDrawing}
|
||||
onChange={(event) => setSaveReplay(event.target.checked)}
|
||||
/>
|
||||
Replay
|
||||
</label>
|
||||
<button type='button' onClick={openTegaki} disabled={isBusy}>
|
||||
{hasDrawing ? 'Edit' : 'Draw'}
|
||||
</button>
|
||||
<button type='button' onClick={clearDrawing} disabled={isBusy || !hasDrawing}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OekakiDrawingControls;
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import PostForm, { LinkTypePreviewer } from '../post-form';
|
||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
|
||||
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -24,10 +25,12 @@ const testState = vi.hoisted(() => ({
|
||||
editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
|
||||
gifFrameStatus: 'idle' as 'idle' | 'ready',
|
||||
handleUploadMock: vi.fn(),
|
||||
uploadFileMock: vi.fn(),
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
isUploading: false,
|
||||
isResolvingExternalQuotes: false,
|
||||
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
|
||||
navigateMock: vi.fn(),
|
||||
offlineTitle: 'offline board',
|
||||
postIndex: undefined as number | undefined,
|
||||
@@ -259,6 +262,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
|
||||
testState.uploadComplete = onUploadComplete;
|
||||
return {
|
||||
handleUpload: testState.handleUploadMock,
|
||||
uploadFile: testState.uploadFileMock,
|
||||
isUploading: testState.isUploading,
|
||||
uploadedFileName: testState.uploadedFileName,
|
||||
};
|
||||
@@ -286,8 +290,9 @@ vi.mock('../../../lib/utils/media-utils', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
||||
getShowUploadControls: () => testState.showUploadControls,
|
||||
isWebRuntime: () => true,
|
||||
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||
@@ -438,6 +443,7 @@ describe('PostForm', () => {
|
||||
testState.isOnlineStatusLoading = false;
|
||||
testState.isUploading = false;
|
||||
testState.isResolvingExternalQuotes = false;
|
||||
testState.mediaHostingRuntime = 'web';
|
||||
testState.offlineTitle = 'offline board';
|
||||
testState.postIndex = undefined;
|
||||
testState.publishedPostOptions = undefined;
|
||||
@@ -456,6 +462,7 @@ describe('PostForm', () => {
|
||||
'traditional-games.bso': { address: 'traditional-games.bso' },
|
||||
};
|
||||
testState.handleUploadMock.mockReset();
|
||||
testState.uploadFileMock.mockReset();
|
||||
testState.navigateMock.mockReset();
|
||||
testState.publishPostMock.mockReset();
|
||||
testState.publishReplyMock.mockReset();
|
||||
@@ -573,6 +580,43 @@ describe('PostForm', () => {
|
||||
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
|
||||
});
|
||||
|
||||
it('shows Oekaki draw controls only on the /i/ board form', async () => {
|
||||
testState.directories.push({
|
||||
address: 'oekaki-posting.bso',
|
||||
directoryCode: 'i',
|
||||
features: { requirePostLink: true, requirePostLinkIsMedia: true },
|
||||
title: '/i/ - Oekaki',
|
||||
});
|
||||
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
|
||||
testState.resolvedCommunityAddress = 'oekaki-posting.bso';
|
||||
|
||||
await renderPostForm('/i');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
const table = container.querySelector('table') as HTMLTableElement;
|
||||
const drawRow = Array.from(table.querySelectorAll('tr')).find((row) => row.textContent?.includes('Size') && row.textContent?.includes('Replay'));
|
||||
expect(table.textContent).toContain('Size');
|
||||
expect(table.textContent).toContain('Replay');
|
||||
expect(Array.from(table.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
|
||||
expect(drawRow?.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
|
||||
expect(Array.from(table.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
|
||||
expect((Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
|
||||
const rulesItems = Array.from(table.querySelectorAll('tr.rules li')).map((item) => item.textContent);
|
||||
expect(rulesItems).toEqual(['Please read the Rules and FAQ before posting.', OEKAKI_WEB_WARNING_TEXT]);
|
||||
|
||||
testState.mediaHostingRuntime = 'electron';
|
||||
await renderPostForm('/i');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
|
||||
|
||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||
await renderPostForm('/mu');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops stale thread content when board navigation remounts the form before a link-only post', async () => {
|
||||
await renderNavigablePostForm('/mu');
|
||||
await clickByText(container, 'start_new_thread');
|
||||
|
||||
@@ -36,11 +36,13 @@ import usePublishPost from '../../hooks/use-publish-post';
|
||||
import usePublishReply from '../../hooks/use-publish-reply';
|
||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import OekakiDrawingControls from '../oekaki-drawing-controls';
|
||||
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
||||
import styles from './post-form.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
@@ -141,6 +143,7 @@ interface PostFormFieldsProps {
|
||||
isUploading: boolean;
|
||||
uploadedFileName: string | null | undefined;
|
||||
showUploadControls: boolean;
|
||||
showOekakiControls: boolean;
|
||||
showSpoilerForPost: boolean;
|
||||
showSpoilerForReply: boolean;
|
||||
isInAllView: boolean;
|
||||
@@ -158,6 +161,8 @@ interface PostFormFieldsProps {
|
||||
onPublishReply: () => void;
|
||||
onPublishPost: () => void;
|
||||
handleUpload: () => void;
|
||||
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
|
||||
onOekakiClearUploadedUrl: (url: string) => void;
|
||||
disableReplyPublish: boolean;
|
||||
}
|
||||
|
||||
@@ -185,6 +190,7 @@ const PostFormFields = ({
|
||||
isUploading,
|
||||
uploadedFileName,
|
||||
showUploadControls,
|
||||
showOekakiControls,
|
||||
showSpoilerForPost,
|
||||
showSpoilerForReply,
|
||||
isInAllView,
|
||||
@@ -202,6 +208,8 @@ const PostFormFields = ({
|
||||
onPublishReply,
|
||||
onPublishPost,
|
||||
handleUpload,
|
||||
uploadFile,
|
||||
onOekakiClearUploadedUrl,
|
||||
disableReplyPublish,
|
||||
}: PostFormFieldsProps) => (
|
||||
<>
|
||||
@@ -365,6 +373,14 @@ const PostFormFields = ({
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showOekakiControls && (
|
||||
<tr>
|
||||
<td>Draw</td>
|
||||
<td>
|
||||
<OekakiDrawingControls disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={onOekakiClearUploadedUrl} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||
<tr className={styles.spoilerButton}>
|
||||
<td>{capitalize(t('spoiler'))}</td>
|
||||
@@ -424,6 +440,7 @@ const PostFormFields = ({
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -455,6 +472,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const nonokoRedirectPathRef = useRef<string | null>(null);
|
||||
|
||||
const location = useLocation();
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
@@ -466,6 +484,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
|
||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
||||
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
|
||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
||||
@@ -600,7 +619,6 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
|
||||
|
||||
// in post page, publish a reply to the post
|
||||
const isInPostView = isPostPageView(location.pathname, params);
|
||||
const cid = params?.commentCid || '';
|
||||
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||
usePublishReply({ cid, communityAddress, postCid });
|
||||
@@ -707,7 +725,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}
|
||||
}, [replyIndex, closeForm, navigate, resetFields]);
|
||||
|
||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
||||
onUploadComplete: (uploadedUrl: string) => {
|
||||
if (uploadedUrl) {
|
||||
setUrl(uploadedUrl);
|
||||
@@ -722,6 +740,21 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleOekakiClearUploadedUrl = useCallback(
|
||||
(uploadedUrl: string) => {
|
||||
if ((urlRef.current?.value || url) !== uploadedUrl) return;
|
||||
setUrl('');
|
||||
if (urlRef.current) {
|
||||
urlRef.current.value = '';
|
||||
}
|
||||
if (isInPostView) {
|
||||
setPublishReplyOptions({ link: '' });
|
||||
} else {
|
||||
setPublishPostOptions({ link: '' });
|
||||
}
|
||||
},
|
||||
[isInPostView, setPublishPostOptions, setPublishReplyOptions, url],
|
||||
);
|
||||
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
||||
|
||||
@@ -765,6 +798,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
isUploading={isUploading}
|
||||
uploadedFileName={uploadedFileName}
|
||||
showUploadControls={showUploadControls}
|
||||
showOekakiControls={showOekakiControls}
|
||||
showSpoilerForPost={showSpoilerForPost}
|
||||
showSpoilerForReply={showSpoilerForReply}
|
||||
isInAllView={isInAllView}
|
||||
@@ -782,6 +816,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
onPublishReply={onPublishReply}
|
||||
onPublishPost={onPublishPost}
|
||||
handleUpload={handleUpload}
|
||||
uploadFile={uploadFile}
|
||||
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
||||
disableReplyPublish={isResolvingExternalQuotes}
|
||||
/>
|
||||
</tbody>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ReplyModal from '../reply-modal';
|
||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../../lib/oekaki/oekaki-copy';
|
||||
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -20,7 +21,9 @@ const testState = vi.hoisted(() => ({
|
||||
},
|
||||
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
|
||||
handleUploadMock: vi.fn(),
|
||||
uploadFileMock: vi.fn(),
|
||||
isMobile: false,
|
||||
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
|
||||
isResolvingExternalQuotes: false,
|
||||
isUploading: false,
|
||||
navigateMock: vi.fn(),
|
||||
@@ -127,8 +130,9 @@ vi.mock('../../../stores/use-reply-modal-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
||||
getShowUploadControls: () => testState.showUploadControls,
|
||||
isWebRuntime: () => true,
|
||||
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||
@@ -185,6 +189,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
|
||||
testState.uploadComplete = onUploadComplete;
|
||||
return {
|
||||
handleUpload: testState.handleUploadMock,
|
||||
uploadFile: testState.uploadFileMock,
|
||||
isUploading: testState.isUploading,
|
||||
uploadedFileName: testState.uploadedFileName,
|
||||
};
|
||||
@@ -352,6 +357,7 @@ describe('ReplyModal', () => {
|
||||
},
|
||||
};
|
||||
testState.handleUploadMock.mockReset();
|
||||
testState.uploadFileMock.mockReset();
|
||||
testState.isMobile = false;
|
||||
testState.isResolvingExternalQuotes = false;
|
||||
testState.isUploading = false;
|
||||
@@ -398,6 +404,7 @@ describe('ReplyModal', () => {
|
||||
testState.uploadComplete = undefined;
|
||||
testState.uploadedFileName = null;
|
||||
testState.uploadMode = 'always';
|
||||
testState.mediaHostingRuntime = 'web';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
@@ -437,6 +444,30 @@ describe('ReplyModal', () => {
|
||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
|
||||
});
|
||||
|
||||
it('shows Oekaki draw controls on /i/ replies', async () => {
|
||||
testState.directoryByAddress['oekaki-posting.bso'] = {
|
||||
address: 'oekaki-posting.bso',
|
||||
directoryCode: 'i',
|
||||
features: { requirePostLink: true, requirePostLinkIsMedia: true },
|
||||
title: '/i/ - Oekaki',
|
||||
};
|
||||
testState.communities['oekaki-posting.bso'] = { address: 'oekaki-posting.bso' };
|
||||
|
||||
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
|
||||
|
||||
expect(container.textContent).toContain('Size');
|
||||
expect(container.textContent).toContain('Replay');
|
||||
expect(Array.from(container.querySelectorAll('span')).some((span) => span.textContent === '×')).toBe(true);
|
||||
expect(container.textContent).toContain(OEKAKI_WEB_WARNING_TEXT);
|
||||
expect(Array.from(container.querySelectorAll('button')).some((button) => button.textContent === 'Draw')).toBe(true);
|
||||
expect((Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Clear') as HTMLButtonElement | undefined)?.disabled).toBe(true);
|
||||
|
||||
testState.mediaHostingRuntime = 'android';
|
||||
await renderReplyModal('/i/thread/post-1', 'oekaki-posting.bso');
|
||||
|
||||
expect(container.textContent).not.toContain(OEKAKI_WEB_WARNING_TEXT);
|
||||
});
|
||||
|
||||
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
|
||||
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
|
||||
|
||||
|
||||
@@ -120,6 +120,32 @@
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.oekakiRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 302px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
|
||||
.oekakiLabel {
|
||||
display: inline-block;
|
||||
width: 38px;
|
||||
padding-top: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.oekakiControls {
|
||||
width: 264px;
|
||||
}
|
||||
|
||||
.oekakiWarning {
|
||||
width: 294px;
|
||||
margin-bottom: 1px;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.offlineBoard {
|
||||
width: 292px;
|
||||
font-family: monospace;
|
||||
|
||||
@@ -28,9 +28,11 @@ import usePublishReply from '../../hooks/use-publish-reply';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
||||
import { OEKAKI_WEB_WARNING_TEXT } from '../../lib/oekaki/oekaki-copy';
|
||||
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
||||
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||
import LoadingEllipsis from '../loading-ellipsis';
|
||||
import OekakiDrawingControls from '../oekaki-drawing-controls';
|
||||
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
||||
import styles from './reply-modal.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
@@ -63,6 +65,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
|
||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
||||
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
|
||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
||||
@@ -429,7 +432,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
checkContentLengthRef.current(publishContent, t);
|
||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
||||
|
||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
||||
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
||||
onUploadComplete: (uploadedUrl: string) => {
|
||||
if (uploadedUrl) {
|
||||
setUrl(uploadedUrl);
|
||||
@@ -440,6 +443,14 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}
|
||||
},
|
||||
});
|
||||
const handleOekakiClearUploadedUrl = (uploadedUrl: string) => {
|
||||
if ((urlRef.current?.value || url) !== uploadedUrl) return;
|
||||
setUrl('');
|
||||
if (urlRef.current) {
|
||||
urlRef.current.value = '';
|
||||
}
|
||||
setPublishReplyOptions({ link: '' });
|
||||
};
|
||||
const uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
||||
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
|
||||
@@ -546,6 +557,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showOekakiControls && (
|
||||
<div className={styles.oekakiRow}>
|
||||
<span className={styles.oekakiLabel}>Draw</span>
|
||||
<OekakiDrawingControls className={styles.oekakiControls} disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={handleOekakiClearUploadedUrl} />
|
||||
</div>
|
||||
)}
|
||||
{showOekakiControls && isWebRuntime() ? <div className={styles.oekakiWarning}>{OEKAKI_WEB_WARNING_TEXT}</div> : null}
|
||||
{flagOptions.length > 0 && (
|
||||
<div>
|
||||
<select
|
||||
|
||||
Reference in New Issue
Block a user