mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'master' of github.com:bitsocialnet/5chan
This commit is contained in:
@@ -3,6 +3,7 @@ package fivechan.android;
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.activity.result.ActivityResult;
|
||||
@@ -31,6 +32,7 @@ public class FileUploaderPlugin extends Plugin {
|
||||
|
||||
private static final String PROVIDER_CATBOX = "catbox";
|
||||
private static final long CATBOX_TIMEOUT_SEC = 30;
|
||||
private static final int MAX_GENERATED_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
@PluginMethod
|
||||
public void pickAndUploadMedia(PluginCall call) {
|
||||
@@ -47,6 +49,62 @@ public class FileUploaderPlugin extends Plugin {
|
||||
startActivityForResult(call, intent, "pickFileResult");
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void uploadGeneratedMedia(PluginCall call) {
|
||||
Log.d(TAG, "uploadGeneratedMedia called");
|
||||
List<String> providerOrder = getProviderOrder(call);
|
||||
if (providerOrder.isEmpty()) {
|
||||
call.reject("No supported upload providers selected");
|
||||
return;
|
||||
}
|
||||
|
||||
String base64 = call.getString("base64");
|
||||
if (base64 == null || base64.trim().isEmpty()) {
|
||||
call.reject("Generated upload data is required");
|
||||
return;
|
||||
}
|
||||
int commaIndex = base64.indexOf(',');
|
||||
String base64Payload = commaIndex >= 0 ? base64.substring(commaIndex + 1) : base64;
|
||||
byte[] bytes;
|
||||
try {
|
||||
bytes = Base64.decode(base64Payload, Base64.DEFAULT);
|
||||
} catch (IllegalArgumentException e) {
|
||||
call.reject("Generated upload data is invalid");
|
||||
return;
|
||||
}
|
||||
if (bytes.length == 0) {
|
||||
call.reject("Generated upload data is empty");
|
||||
return;
|
||||
}
|
||||
if (bytes.length > MAX_GENERATED_UPLOAD_BYTES) {
|
||||
call.reject("Generated upload is too large");
|
||||
return;
|
||||
}
|
||||
|
||||
String fileName = call.getString("fileName", "tegaki.png");
|
||||
String mimeType = call.getString("mimeType", "application/octet-stream");
|
||||
new Thread(
|
||||
() -> {
|
||||
File cachedFile = null;
|
||||
try {
|
||||
cachedFile = FileUtils.writeBytesToCacheFile(getContext(), fileName, bytes);
|
||||
tryProvidersSequentially(Uri.fromFile(cachedFile), providerOrder, call, fileName, bytes, mimeType);
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Generated upload failed", e);
|
||||
try {
|
||||
call.reject("Upload failed: " + e.getMessage());
|
||||
} catch (Exception rejectEx) {
|
||||
Log.e(TAG, "Failed to reject generated upload call", rejectEx);
|
||||
}
|
||||
} finally {
|
||||
if (cachedFile != null && cachedFile.exists() && !cachedFile.delete()) {
|
||||
Log.w(TAG, "Could not delete generated upload cache file: " + cachedFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
private List<String> parseProviderOrder(PluginCall call) {
|
||||
List<String> order = new ArrayList<>();
|
||||
JSArray arr = call.getArray("providerOrder");
|
||||
@@ -127,8 +185,19 @@ public class FileUploaderPlugin extends Plugin {
|
||||
}
|
||||
|
||||
private void tryProvidersSequentially(Uri fileUri, List<String> providerOrder, PluginCall call) {
|
||||
tryProvidersSequentially(fileUri, providerOrder, call, null, null, null);
|
||||
}
|
||||
|
||||
private void tryProvidersSequentially(
|
||||
Uri fileUri,
|
||||
List<String> providerOrder,
|
||||
PluginCall call,
|
||||
String generatedFileName,
|
||||
byte[] generatedFileBytes,
|
||||
String generatedMimeType) {
|
||||
List<JSObject> attempts = new ArrayList<>();
|
||||
StringBuilder errorSummary = new StringBuilder();
|
||||
String fileName = generatedFileName != null ? generatedFileName : getFileName(fileUri);
|
||||
|
||||
for (String provider : providerOrder) {
|
||||
JSObject attempt = new JSObject();
|
||||
@@ -140,18 +209,21 @@ public class FileUploaderPlugin extends Plugin {
|
||||
if (res.success) {
|
||||
attempt.put("url", res.url);
|
||||
attempts.add(attempt);
|
||||
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
|
||||
resolveWithSuccess(call, res.url, fileName, provider, attempts);
|
||||
return;
|
||||
}
|
||||
attempt.put("error", res.error);
|
||||
errorSummary.append(provider).append(": ").append(res.error).append("; ");
|
||||
} else if (MediaUploadRecipes.isWebViewProvider(provider)) {
|
||||
MediaUploadResult res = uploadViaWebViewSync(fileUri, provider);
|
||||
MediaUploadResult res =
|
||||
generatedFileBytes != null
|
||||
? uploadGeneratedViaWebViewSync(generatedFileBytes, fileName, generatedMimeType, provider)
|
||||
: uploadViaWebViewSync(fileUri, provider);
|
||||
attempt.put("success", res.success);
|
||||
if (res.success) {
|
||||
attempt.put("url", res.url);
|
||||
attempts.add(attempt);
|
||||
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
|
||||
resolveWithSuccess(call, res.url, fileName, provider, attempts);
|
||||
return;
|
||||
}
|
||||
attempt.put("error", res.error);
|
||||
@@ -251,4 +323,54 @@ public class FileUploaderPlugin extends Plugin {
|
||||
return new MediaUploadResult(false, null, "Interrupted");
|
||||
}
|
||||
}
|
||||
|
||||
private MediaUploadResult uploadGeneratedViaWebViewSync(
|
||||
byte[] fileBytes, String fileName, String mimeType, String provider) {
|
||||
JSObject statusUpdate = new JSObject();
|
||||
statusUpdate.put("status", "Uploading to " + provider + "...");
|
||||
notifyListeners("uploadStatus", statusUpdate);
|
||||
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
|
||||
|
||||
AppCompatActivity activity = getActivity();
|
||||
if (activity == null) {
|
||||
return new MediaUploadResult(false, null, "Activity unavailable");
|
||||
}
|
||||
|
||||
MediaUploadCallback callback =
|
||||
res -> {
|
||||
resultRef.set(res);
|
||||
latch.countDown();
|
||||
};
|
||||
|
||||
activity.runOnUiThread(
|
||||
() -> {
|
||||
MediaUploadAutomationRunner runner =
|
||||
new MediaUploadAutomationRunner(
|
||||
getContext(),
|
||||
fileBytes,
|
||||
fileName,
|
||||
mimeType,
|
||||
provider,
|
||||
callback,
|
||||
null);
|
||||
runner.run();
|
||||
});
|
||||
|
||||
try {
|
||||
boolean ok =
|
||||
latch.await(
|
||||
MediaUploadRecipes.getUploadTimeoutMs(provider) + 5000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
if (!ok) {
|
||||
return new MediaUploadResult(false, null, "WebView upload timeout");
|
||||
}
|
||||
MediaUploadResult r = resultRef.get();
|
||||
return r != null ? r : new MediaUploadResult(false, null, "No result");
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return new MediaUploadResult(false, null, "Interrupted");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,13 @@ public class FileUtils {
|
||||
private static final Pattern UNSAFE_CHARS = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||
|
||||
public static File getFileFromUri(Context context, Uri uri) throws Exception {
|
||||
if ("file".equals(uri.getScheme()) && uri.getPath() != null) {
|
||||
File source = new File(uri.getPath());
|
||||
if (source.isFile()) {
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
String fileName = getFileName(context, uri);
|
||||
String sanitizedName = sanitizeFileName(fileName);
|
||||
File file = new File(context.getCacheDir(), sanitizedName);
|
||||
@@ -69,6 +76,25 @@ public class FileUtils {
|
||||
* Takes only the last path segment, removes/replaces unsafe chars, enforces max length,
|
||||
* and falls back to a UUID if the result is empty.
|
||||
*/
|
||||
public static File writeBytesToCacheFile(Context context, String fileName, byte[] bytes) throws Exception {
|
||||
String safeName = sanitizeFileName(fileName);
|
||||
int dot = safeName.lastIndexOf('.');
|
||||
String base = dot > 0 ? safeName.substring(0, dot) : safeName;
|
||||
String ext = dot > 0 ? safeName.substring(dot) : null;
|
||||
if (base.length() < 3) {
|
||||
base = (base + "___").substring(0, 3);
|
||||
}
|
||||
if (base.length() > 64) {
|
||||
base = base.substring(0, 64);
|
||||
}
|
||||
File file = File.createTempFile(base + "-", ext, context.getCacheDir());
|
||||
try (FileOutputStream outputStream = new FileOutputStream(file)) {
|
||||
outputStream.write(bytes);
|
||||
outputStream.flush();
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
private static String sanitizeFileName(String fileName) {
|
||||
if (fileName == null || fileName.isEmpty()) {
|
||||
return UUID.randomUUID().toString();
|
||||
@@ -82,4 +108,4 @@ public class FileUtils {
|
||||
}
|
||||
return basename;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,32 @@ ipcMain.on('get-pkc-rpc-auth-key', (event) => event.reply('pkc-rpc-auth-key', pk
|
||||
// NOTE: eventually should probably fake sec-ch-ua header as well
|
||||
const fakeUserAgent = createFakeUserAgent();
|
||||
const realUserAgent = `5chan/${packageJson.version}`;
|
||||
const MAX_GENERATED_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||
const SAFE_GENERATED_UPLOAD_NAME_RE = /[^a-zA-Z0-9._-]/g;
|
||||
|
||||
const sanitizeGeneratedUploadFileName = (fileName) => {
|
||||
const basename = path.basename(typeof fileName === 'string' && fileName ? fileName : 'tegaki.png').replace(/\.\./g, '');
|
||||
const sanitized = basename.replace(SAFE_GENERATED_UPLOAD_NAME_RE, '_').trim();
|
||||
return sanitized && sanitized.length <= 255 ? sanitized : 'tegaki.png';
|
||||
};
|
||||
|
||||
const writeGeneratedUploadFile = async ({ fileName, bytes }) => {
|
||||
if (!Array.isArray(bytes) || bytes.length === 0) {
|
||||
throw new Error('Generated upload bytes are required');
|
||||
}
|
||||
if (bytes.length > MAX_GENERATED_UPLOAD_BYTES) {
|
||||
throw new Error('Generated upload is too large');
|
||||
}
|
||||
if (!bytes.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255)) {
|
||||
throw new Error('Generated upload bytes are invalid');
|
||||
}
|
||||
const buffer = Buffer.from(bytes);
|
||||
const safeFileName = sanitizeGeneratedUploadFileName(fileName);
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(app.getPath('temp'), '5chan-upload-'));
|
||||
const filePath = path.join(tempDir, safeFileName);
|
||||
await fs.promises.writeFile(filePath, buffer);
|
||||
return { tempDir, filePath };
|
||||
};
|
||||
|
||||
// add right click menu
|
||||
contextMenu({
|
||||
@@ -391,6 +417,20 @@ ipcMain.handle('automate-upload-media', async (event, options) => {
|
||||
return automateUploadMedia({ provider, filePath });
|
||||
});
|
||||
|
||||
ipcMain.handle('automate-upload-generated-media', async (event, options) => {
|
||||
const { provider, fileName, bytes } = options || {};
|
||||
if (!provider) {
|
||||
throw new Error('automate-upload-generated-media requires a provider');
|
||||
}
|
||||
|
||||
const { tempDir, filePath } = await writeGeneratedUploadFile({ fileName, bytes });
|
||||
try {
|
||||
return await automateUploadMedia({ provider, filePath });
|
||||
} finally {
|
||||
await fs.promises.rm(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('download-and-install-update', async (event, options) => {
|
||||
const { url, fileName } = options || {};
|
||||
return downloadAndInstallUpdate({ url, fileName });
|
||||
|
||||
@@ -21,6 +21,7 @@ contextBridge.exposeInMainWorld('electronApi', {
|
||||
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||
automateUploadMedia: (options) => ipcRenderer.invoke('automate-upload-media', options),
|
||||
automateUploadGeneratedMedia: (options) => ipcRenderer.invoke('automate-upload-generated-media', options),
|
||||
downloadAndInstallUpdate: (options) => ipcRenderer.invoke('download-and-install-update', options),
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
Copyright (c) 2015 Maxime Youdine
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
+669
@@ -0,0 +1,669 @@
|
||||
/*! Contains fonts from Font Awesome (Copyright (C) 2016 by Dave Gandy), Entypo (Copyright (C) 2012 by Daniel Bruce) */
|
||||
@font-face {
|
||||
font-family: tegaki;
|
||||
src: url('data:application/octet-stream;base64,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') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.tegaki-icon:before {
|
||||
font-family: tegaki;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
speak: none;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
.tegaki-cancel:before { content: '\e800'; } /* '' */
|
||||
.tegaki-plus:before { content: '\e801'; } /* '' */
|
||||
.tegaki-minus:before { content: '\e802'; } /* '' */
|
||||
.tegaki-pen:before { content: '\e803'; } /* '' */
|
||||
.tegaki-down-open:before { content: '\e804'; } /* '' */
|
||||
.tegaki-up-open:before { content: '\e805'; } /* '' */
|
||||
.tegaki-level-down:before { content: '\e806'; } /* '' */
|
||||
.tegaki-pencil:before { content: '\e807'; } /* '' */
|
||||
.tegaki-play:before { content: '\e808'; } /* '' */
|
||||
.tegaki-bucket:before { content: '\e809'; } /* '' */
|
||||
.tegaki-pause:before { content: '\e80a'; } /* '' */
|
||||
.tegaki-blur:before { content: '\e80b'; } /* '' */
|
||||
.tegaki-to-start:before { content: '\e80c'; } /* '' */
|
||||
.tegaki-watercolor:before { content: '\e80d'; } /* '' */
|
||||
.tegaki-tone:before { content: '\e80e'; } /* '' */
|
||||
.tegaki-airbrush:before { content: '\e80f'; } /* '' */
|
||||
.tegaki-fast-fw:before { content: '\e810'; } /* '' */
|
||||
.tegaki-fast-bw:before { content: '\e811'; } /* '' */
|
||||
.tegaki-left-open:before { content: '\e812'; } /* '' */
|
||||
.tegaki-right-open:before { content: '\e813'; } /* '' */
|
||||
.tegaki-eraser:before { content: '\f12d'; } /* '' */
|
||||
.tegaki-pipette:before { content: '\f1fb'; } /* '' */
|
||||
|
||||
.tegaki-disabled,
|
||||
.tegaki-disabled::after,
|
||||
.tegaki-disabled::before {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.tegaki-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tegaki-invis {
|
||||
visibility: hidden !important;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.tegaki-replay-mode #tegaki-tools-cnt,
|
||||
.tegaki-replay-mode #tegaki-toolmode-bar,
|
||||
.tegaki-replay-mode .tegaki-ctrlgrp,
|
||||
.tegaki-replay-mode .tegaki-layers-cell,
|
||||
.tegaki-replay-mode #tegaki-layers-ctrl {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tegaki-replay-mode #tegaki-ctrlgrp-zoom,
|
||||
.tegaki-replay-mode #tegaki-ctrlgrp-layers {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#tegaki {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #a3b1bf;
|
||||
color: #222;
|
||||
font-family: arial, sans-serif;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
z-index: 9999;
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 160px;
|
||||
grid-template-rows: 24px 1fr 18px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
#tegaki input {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#tegaki > div {
|
||||
background-color: #8d99a6;
|
||||
}
|
||||
|
||||
#tegaki-menu-cnt {
|
||||
grid-area: 1 / 1 / 2 / 4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#tegaki-tools-cnt {
|
||||
grid-area: 2 / 1 / 4 / 2;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#tegaki-canvas-cnt {
|
||||
grid-area: 2 / 2 / 3 / 3;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#tegaki-ctrl-cnt {
|
||||
grid-area: 2 / 3 / 4 / 4;
|
||||
padding: 6px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
#tegaki-status-cnt {
|
||||
grid-area: 3 / 2 / 4 / 3;
|
||||
line-height: 18px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#tegaki-status-cnt > div {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
#tegaki-status-replay {
|
||||
color: #a61930;
|
||||
}
|
||||
|
||||
#tegaki-status-output {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#tegaki-status-version {
|
||||
color: #adbdcc;
|
||||
font-size: 11px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#tegaki-menu-bar {
|
||||
font-size: 12px;
|
||||
padding-left: 4px;
|
||||
padding-right: 18px;
|
||||
border-right: 2px solid #a3b1bf;
|
||||
}
|
||||
|
||||
.tegaki-replay-mode #tegaki-menu-bar {
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.tegaki-menu-lbl {
|
||||
margin: 0 2px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#tegaki-replay-controls {
|
||||
padding-right: 10px;
|
||||
padding-left: 10px;
|
||||
border-right: 2px solid #a3b1bf;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#tegaki-replay-timeline {
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
height: 24px;
|
||||
margin: 0 4px;
|
||||
border-left: 1px solid #a3b1bf;
|
||||
border-right: 1px solid #a3b1bf;
|
||||
/* background-color: rgba(0, 0, 0, 0.25); */
|
||||
}
|
||||
|
||||
#tegaki-replay-timeline-fill {
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
background-color: #a3b1bf;
|
||||
}
|
||||
|
||||
#tegaki-replay-controls > span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
#tegaki-replay-controls .tegaki-ui-cb-w {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
#tegaki-replay-speed-lbl {
|
||||
width: 28px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#tegaki-replay-speed-lbl::before {
|
||||
content: '×';
|
||||
}
|
||||
|
||||
#tegaki-replay-now-lbl,
|
||||
#tegaki-replay-end-lbl {
|
||||
display: inline-block;
|
||||
max-width: 50px;
|
||||
min-width: 30px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
#tegaki-toolmode-bar {
|
||||
font-size: 11px;
|
||||
margin-left: 4px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.tegaki-toolmode-lbl {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tegaki-toolmode-lbl::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.tegaki-toolmode-grp {
|
||||
border-left: 1px solid #a3b1bf;
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
#tegaki canvas {
|
||||
image-rendering: optimizespeed;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
image-rendering: pixelated;
|
||||
-ms-interpolation-mode: nearest-neighbor;
|
||||
}
|
||||
|
||||
.tegaki-tool-active {
|
||||
color: #f2f3f4;
|
||||
}
|
||||
|
||||
.tegaki-tool-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.tegaki-tool-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.tegaki-tool-btn:before {
|
||||
font-size: 20px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.tegaki-mb-btn {
|
||||
cursor: default;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
padding: 0 6px;
|
||||
word-spacing: -1px;
|
||||
position: relative;
|
||||
line-height: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.tegaki-mb-btn:hover:not(.tegaki-disabled),
|
||||
.tegaki-ui-btn:hover:not(.tegaki-disabled) {
|
||||
background-color: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
.tegaki-sw-btn {
|
||||
display: inline;
|
||||
padding: 2px 6px;
|
||||
margin: 0 2px;
|
||||
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.15), -1px -1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tegaki-sw-btn:hover:not(.tegaki-sw-btn-a) {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.tegaki-sw-btn-a {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: -1px -1px 0 rgba(0, 0, 0, 0.15), 1px 1px 0 rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
#tegaki-toolmode-bar .tegaki-mb-btn-a {
|
||||
color: inherit;
|
||||
background-color: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
#tegaki-toolmode-bar .tegaki-mb-btn.tegaki-mb-btn-a:hover {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
#tegaki-debug {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#tegaki-debug canvas {
|
||||
width: 75px;
|
||||
height: 75px;
|
||||
display: block;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.tegaki-backdrop {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tegaki-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.tegaki-strike {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
#tegaki-layers {
|
||||
position: relative;
|
||||
font-size: 0;
|
||||
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.25);
|
||||
contain: content;
|
||||
}
|
||||
|
||||
#tegaki-layers canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#tegaki-layers:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#tegaki-layers-wrap {
|
||||
margin: auto;
|
||||
padding: 50px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#tegaki-layers canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#tegaki-finish-btn {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#tegaki-cursor-layer {
|
||||
position: absolute;
|
||||
mix-blend-mode: difference;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* generic ui */
|
||||
.tegaki-alpha-bg,
|
||||
.tegaki-alpha-bg-xs {
|
||||
background-color: #fefefe;
|
||||
background-image:
|
||||
linear-gradient(45deg, #cacaca 25%, transparent 25%, transparent 75%, #cacaca 75%, #cacaca),
|
||||
linear-gradient(45deg, #cacaca 25%, transparent 25%, transparent 75%, #cacaca 75%, #cacaca);
|
||||
}
|
||||
|
||||
.tegaki-alpha-bg {
|
||||
background-size: 16px 16px;
|
||||
background-position: 0 0, 8px 8px;
|
||||
}
|
||||
|
||||
.tegaki-alpha-bg-xs {
|
||||
background-size: 6px 6px;
|
||||
background-position: 0 0, 3px 3px;
|
||||
}
|
||||
|
||||
.tegaki-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tegaki-ui-cb {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 1px solid #222;
|
||||
cursor: default;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tegaki-ui-cb::after,
|
||||
.tegaki-ui-cb-a::after {
|
||||
display: block;
|
||||
content: ' ';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-top: 2px;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.tegaki-ui-cb-a::after {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.tegaki-ui-cb-w:hover .tegaki-ui-cb::after,
|
||||
.tegaki-ui-cb:hover::after {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.tegaki-ui-ellipsis::after {
|
||||
content: '...';
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.tegaki-ui-borderless {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tegaki-ui-btn {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tegaki-ui-btn:before {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
line-height: 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tegaki-stealth-input {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.tegaki-stealth-input:hover:not(.tegaki-disabled) {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.tegaki-range-lbl,
|
||||
.tegaki-range-lbl-xs {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tegaki-range-lbl {
|
||||
width: 28px;
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.tegaki-range-lbl-xs {
|
||||
width: 20px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tegaki-label-xs {
|
||||
font-size: 10px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.tegaki-lbl-c::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.tegaki-lbl-p::after {
|
||||
content: '%';
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
.tegaki-drag-lbl:not(.tegaki-disabled) {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.tegaki-disabled .tegaki-drag-lbl {
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
/* control groups */
|
||||
.tegaki-ctrlgrp {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tegaki-ctrlgrp:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tegaki-ctrlgrp-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 6px;
|
||||
background-color: #a3b1bf;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.tegaki-ctrlrow {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tegaki-ctrlrow:not(:last-child) {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tegaki-ctrl-range {
|
||||
width: calc(100% - 34px);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* zoom ctrl group */
|
||||
#tegaki-zoom-lbl {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
float: right;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
/* color ctrl group */
|
||||
#tegaki-color-ctrl {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#tegaki-palette-switcher {
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tegaki-color-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.tegaki-color-grid-20 {
|
||||
grid-template-columns: repeat(auto-fill, 20px);
|
||||
grid-auto-rows: 20px;
|
||||
}
|
||||
|
||||
.tegaki-color-grid-15 {
|
||||
grid-template-columns: repeat(auto-fill, 15px);
|
||||
grid-auto-rows: 15px;
|
||||
}
|
||||
|
||||
.tegaki-color-btn {
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
#tegaki-color,
|
||||
#tegaki-colorpicker {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
display: block;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
/* layers ctrl group */
|
||||
#tegaki-ctrlgrp-layers {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#tegaki-layers-opts {
|
||||
height: 18px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#tegaki-layer-alpha-cell {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
#tegaki-layers-ctrl {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#tegaki-layers-grid {
|
||||
height: 84px;
|
||||
min-height: 84px;
|
||||
overflow: auto;
|
||||
background-color: #8d99a6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #a3b1bf;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell {
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 0 0px #a3b1bf;
|
||||
padding: 0;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-s,
|
||||
.tegaki-layers-cell-a {
|
||||
background-color: #a3b1bf7f;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-a {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-v {
|
||||
margin: 0 6px 0 4px;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-v .tegaki-ui-cb {
|
||||
vertical-align: unset;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-p {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-p canvas {
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-s .tegaki-layers-cell-p canvas {
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 1.0);
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-n {
|
||||
font-size: 11px;
|
||||
margin-right: 1px;
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.tegaki-layers-cell-d {
|
||||
box-shadow: inset 0 -2px 0 0 #000;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
#tegaki-layers-grid.tegaki-layers-cell-d {
|
||||
box-shadow: 0 -2px 0 0px #000;
|
||||
}
|
||||
|
||||
#tegaki-layers-cell-dx {
|
||||
position: absolute;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-top: -32px;
|
||||
}
|
||||
+5
File diff suppressed because one or more lines are too long
@@ -41,6 +41,7 @@ const DIRECTORY_CODE_ORDER = [
|
||||
'wsg',
|
||||
'diy',
|
||||
'out',
|
||||
'i',
|
||||
'ic',
|
||||
'mu',
|
||||
'int',
|
||||
@@ -259,7 +260,10 @@ const loadDirectoriesSource = async () => {
|
||||
throw new Error('Invalid GitHub directory listing');
|
||||
}
|
||||
|
||||
const fileNames = contents.map((entry) => (isRecord(entry) ? entry.name : undefined)).filter((name) => typeof name === 'string' && isDirectoryListFile(name)).sort();
|
||||
const fileNames = contents
|
||||
.map((entry) => (isRecord(entry) ? entry.name : undefined))
|
||||
.filter((name) => typeof name === 'string' && isDirectoryListFile(name))
|
||||
.sort();
|
||||
const defaults = normalizeDirectoryDefaultsData(await fetchJson(`${GITHUB_RAW_BASE_URL}/${DEFAULTS_FILE_NAME}`));
|
||||
const directories = await Promise.all(
|
||||
fileNames.map(async (fileName) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"title": "5chan directories",
|
||||
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
|
||||
"createdAt": 1779182014,
|
||||
"updatedAt": 1779279785,
|
||||
"updatedAt": 1780054375,
|
||||
"directories": [
|
||||
{
|
||||
"directoryCode": "a",
|
||||
@@ -458,6 +458,32 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"directoryCode": "i",
|
||||
"title": "/i/ - Oekaki",
|
||||
"description": "Boards competing to host the /i/ directory on 5chan. The highest-scoring board resolves the directory code; if it goes offline, 5chan rotates to the next-highest. Anyone can open a PR on this file to add their board.\n\nhttps://github.com/bitsocialnet/lists/blob/master/5chan-directories/5chan-i-directory.json",
|
||||
"features": {
|
||||
"pseudonymityMode": "per-reply",
|
||||
"safeForWork": false,
|
||||
"hasFlags": false,
|
||||
"requirePostLink": true,
|
||||
"requirePostLinkIsMedia": true,
|
||||
"bumpLimit": 300,
|
||||
"noSpoilers": true,
|
||||
"noSpoilerReplies": true,
|
||||
"postsPerPage": 15
|
||||
},
|
||||
"createdAt": 1780054375,
|
||||
"updatedAt": 1780054375,
|
||||
"boards": [
|
||||
{
|
||||
"address": "oekaki-posting.bso",
|
||||
"publicKey": "12D3KooWNAqMhmHi3hPSbcH27Q7cqBCuN2Mu9HNzs6fB8YX14k4o",
|
||||
"owner": "plebeius.bso",
|
||||
"addedAt": 1780054375
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"directoryCode": "ic",
|
||||
"title": "/ic/ - Artwork/Critique",
|
||||
|
||||
Vendored
+6
@@ -14,6 +14,12 @@ declare global {
|
||||
copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>;
|
||||
automateUploadMedia: (options: { provider: ProviderId; filePath: string }) => Promise<{ url: string; provider: ProviderId }>;
|
||||
automateUploadGeneratedMedia?: (options: {
|
||||
provider: ProviderId;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: number[];
|
||||
}) => Promise<{ url: string; provider: ProviderId }>;
|
||||
downloadAndInstallUpdate?: (options: { url: string; fileName: string }) => Promise<void>;
|
||||
getPathForFile?: (file: File) => string | null;
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock('@capacitor/core', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../plugins/file-uploader', () => ({
|
||||
default: { pickAndUploadMedia: vi.fn() },
|
||||
default: { pickAndUploadMedia: vi.fn(), uploadGeneratedMedia: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/utils/catbox-utils', () => ({
|
||||
@@ -211,6 +211,77 @@ describe('useFileUpload', () => {
|
||||
expect(hook().isUploading).toBe(false);
|
||||
});
|
||||
|
||||
it('uploads a generated file via Electron orchestrator', async () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
preferredProviderRef.value = 'catbox';
|
||||
vi.mocked(Capacitor.getPlatform).mockReturnValue('web');
|
||||
window.electronApi = { isElectron: true } as any;
|
||||
vi.mocked(orchestrateElectronUpload).mockResolvedValue('https://files.catbox.moe/tegaki.png');
|
||||
|
||||
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
|
||||
const { onUploadComplete, hook } = mountHook();
|
||||
|
||||
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
|
||||
await act(async () => {
|
||||
result = await hook().uploadFile(file);
|
||||
});
|
||||
|
||||
expect(orchestrateElectronUpload).toHaveBeenCalledWith(file, ['catbox']);
|
||||
expect(result).toEqual({ url: 'https://files.catbox.moe/tegaki.png', fileName: 'tegaki.png' });
|
||||
expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/tegaki.png', 'tegaki.png');
|
||||
expect(hook().uploadedFileName).toBe('tegaki.png');
|
||||
expect(hook().isUploading).toBe(false);
|
||||
});
|
||||
|
||||
it('uploads a generated file via Android plugin', async () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
preferredProviderRef.value = 'catbox';
|
||||
vi.mocked(Capacitor.getPlatform).mockReturnValue('android');
|
||||
vi.mocked(FileUploader.uploadGeneratedMedia).mockResolvedValue({
|
||||
url: 'https://files.catbox.moe/android-tegaki.png',
|
||||
fileName: 'tegaki.png',
|
||||
provider: 'catbox',
|
||||
});
|
||||
|
||||
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
|
||||
const { onUploadComplete, hook } = mountHook();
|
||||
|
||||
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
|
||||
await act(async () => {
|
||||
result = await hook().uploadFile(file);
|
||||
});
|
||||
|
||||
expect(FileUploader.uploadGeneratedMedia).toHaveBeenCalledWith({
|
||||
providerOrder: ['catbox'],
|
||||
fileName: 'tegaki.png',
|
||||
mimeType: 'image/png',
|
||||
base64: 'YWJj',
|
||||
});
|
||||
expect(result).toEqual({ url: 'https://files.catbox.moe/android-tegaki.png', fileName: 'tegaki.png' });
|
||||
expect(onUploadComplete).toHaveBeenCalledWith('https://files.catbox.moe/android-tegaki.png', 'tegaki.png');
|
||||
expect(hook().uploadedFileName).toBe('tegaki.png');
|
||||
});
|
||||
|
||||
it('does not upload generated files from web runtime', async () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
preferredProviderRef.value = 'catbox';
|
||||
vi.mocked(Capacitor.getPlatform).mockReturnValue('web');
|
||||
window.electronApi = undefined;
|
||||
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
|
||||
const { onUploadComplete, hook } = mountHook();
|
||||
|
||||
let result: Awaited<ReturnType<HookSnapshot['uploadFile']>> | null = null;
|
||||
await act(async () => {
|
||||
result = await hook().uploadFile(file);
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(window.alert).toHaveBeenCalledWith('upload_not_supported_web');
|
||||
expect(FileUploader.uploadGeneratedMedia).not.toHaveBeenCalled();
|
||||
expect(orchestrateElectronUpload).not.toHaveBeenCalled();
|
||||
expect(onUploadComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('silently ignores file selection cancellation', async () => {
|
||||
uploadModeRef.value = 'preferred';
|
||||
preferredProviderRef.value = 'catbox';
|
||||
|
||||
+109
-34
@@ -27,6 +27,7 @@ const ANDROID_STAGE_MAP: Record<string, UploadAttemptStage> = {
|
||||
};
|
||||
|
||||
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
|
||||
const WEB_UPLOAD_NOT_SUPPORTED_ERROR = 'Web upload is not supported';
|
||||
|
||||
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'imgbb'];
|
||||
|
||||
@@ -88,6 +89,24 @@ interface UseFileUploadOptions {
|
||||
onUploadComplete: (url: string, fileName: string) => void;
|
||||
}
|
||||
|
||||
export interface UploadedFileResult {
|
||||
url: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Could not read file'));
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
const marker = result.indexOf(',');
|
||||
resolve(marker >= 0 ? result.slice(marker + 1) : result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function selectFileViaInput(): Promise<File | null> {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
@@ -151,32 +170,97 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
||||
|
||||
const getAvailableProviderOrder = useCallback(async () => {
|
||||
const runtime = getMediaHostingRuntime();
|
||||
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
|
||||
const availability = await ensureProviderAvailability(runtime);
|
||||
const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime, availability });
|
||||
|
||||
if (order.length === 0) {
|
||||
if (runtime === 'web') {
|
||||
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
|
||||
}
|
||||
if (supportedOrder.length > 0) {
|
||||
const message =
|
||||
uploadMode === 'preferred' && availability[preferredProvider] === 'unavailable'
|
||||
? `${preferredProvider} is unavailable from this network`
|
||||
: 'No reachable upload providers are available from this network';
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(`${preferredProvider} is not supported on ${runtime}`);
|
||||
}
|
||||
|
||||
return { runtime, order };
|
||||
}, [uploadMode, preferredProvider]);
|
||||
|
||||
const handleUploadError = useCallback(
|
||||
(error: unknown) => {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
|
||||
if (errorMessage === WEB_UPLOAD_NOT_SUPPORTED_ERROR) {
|
||||
window.alert(t('upload_not_supported_web'));
|
||||
return;
|
||||
}
|
||||
|
||||
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
|
||||
if (err.attempts && err.attempts.length > 0) {
|
||||
window.alert(formatAggregatedError(err.attempts, t));
|
||||
} else if (uploadMode === 'preferred') {
|
||||
window.alert(formatPreferredModeError(errorMessage, t));
|
||||
} else {
|
||||
window.alert(`${t('upload_failed')}: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
[t, uploadMode],
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File): Promise<UploadedFileResult | null> => {
|
||||
if (uploadMode === 'none') return null;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadedFileName(null);
|
||||
const { runtime, order } = await getAvailableProviderOrder();
|
||||
let result: UploadedFileResult | null = null;
|
||||
|
||||
if (runtime === 'android') {
|
||||
const pluginResult = await FileUploader.uploadGeneratedMedia({
|
||||
providerOrder: order,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
base64: await readFileAsBase64(file),
|
||||
});
|
||||
result = pluginResult.url ? { url: pluginResult.url, fileName: pluginResult.fileName || file.name } : null;
|
||||
} else if (runtime === 'electron') {
|
||||
const url = await orchestrateElectronUpload(file, order);
|
||||
result = { url, fileName: file.name };
|
||||
} else {
|
||||
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
|
||||
}
|
||||
|
||||
if (result?.url) {
|
||||
setUploadedFileName(result.fileName);
|
||||
onUploadComplete(result.url, result.fileName);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
handleUploadError(error);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
if (uploadMode === 'none') return;
|
||||
|
||||
const runtime = getMediaHostingRuntime();
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadedFileName(null);
|
||||
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
|
||||
const availability = await ensureProviderAvailability(runtime);
|
||||
const order = getProviderOrder({ mode: uploadMode, preferredProvider, runtime, availability });
|
||||
|
||||
if (order.length === 0) {
|
||||
if (runtime === 'web') {
|
||||
window.alert(t('upload_not_supported_web'));
|
||||
return;
|
||||
}
|
||||
if (supportedOrder.length > 0) {
|
||||
const message =
|
||||
uploadMode === 'preferred' && availability[preferredProvider] === 'unavailable'
|
||||
? `${preferredProvider} is unavailable from this network`
|
||||
: 'No reachable upload providers are available from this network';
|
||||
throw new Error(message);
|
||||
}
|
||||
throw new Error(`${preferredProvider} is not supported on ${runtime}`);
|
||||
}
|
||||
|
||||
const { runtime, order } = await getAvailableProviderOrder();
|
||||
if (runtime === 'android') {
|
||||
const result = await FileUploader.pickAndUploadMedia({ providerOrder: order });
|
||||
if (result.url) {
|
||||
@@ -186,7 +270,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isElectronRuntime()) {
|
||||
if (runtime === 'electron' || isElectronRuntime()) {
|
||||
const file = await selectFileViaInput();
|
||||
if (!file) {
|
||||
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
|
||||
@@ -198,27 +282,18 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.alert(t('upload_not_supported_web'));
|
||||
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage === FILE_SELECTION_CANCELLED_ERROR) return;
|
||||
|
||||
const err = normalizeAndroidRejection(error) as Error & { attempts?: ProviderAttempt[] };
|
||||
if (err.attempts && err.attempts.length > 0) {
|
||||
window.alert(formatAggregatedError(err.attempts, t));
|
||||
} else if (uploadMode === 'preferred') {
|
||||
window.alert(formatPreferredModeError(errorMessage, t));
|
||||
} else {
|
||||
window.alert(`${t('upload_failed')}: ${errorMessage}`);
|
||||
}
|
||||
handleUploadError(error);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [onUploadComplete, t, uploadMode, preferredProvider]);
|
||||
}, [getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode]);
|
||||
|
||||
return {
|
||||
isUploading,
|
||||
uploadedFileName,
|
||||
handleUpload,
|
||||
uploadFile,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ function createElectronApiMock() {
|
||||
copyToClipboard: vi.fn(async () => ({ success: true })),
|
||||
getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })),
|
||||
automateUploadMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/abc.png', provider: options.provider })),
|
||||
automateUploadGeneratedMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/generated.png', provider: options.provider })),
|
||||
getPathForFile: vi.fn((): string | null => '/tmp/image.png'),
|
||||
};
|
||||
}
|
||||
@@ -67,6 +68,7 @@ describe('orchestrateElectronUpload', () => {
|
||||
it('fails with provider attempt details if no file path can be resolved', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.getPathForFile = vi.fn((): string | null => null);
|
||||
electronApi.automateUploadGeneratedMedia = undefined as unknown as typeof electronApi.automateUploadGeneratedMedia;
|
||||
window.electronApi = electronApi;
|
||||
|
||||
const file = new File(['z'], 'z.png', { type: 'image/png' });
|
||||
@@ -80,12 +82,30 @@ describe('orchestrateElectronUpload', () => {
|
||||
};
|
||||
expect(typedError.message).toBe('All providers failed');
|
||||
expect(typedError.attempts?.[0]?.provider).toBe('imgur');
|
||||
expect(typedError.attempts?.[0]?.error).toContain('File path required for Electron automation');
|
||||
expect(typedError.attempts?.[0]?.error).toContain('File path unavailable and automateUploadGeneratedMedia is not available');
|
||||
expect(typedError.attempts?.[0]?.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(typedError.attempts?.[0]?.stage).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses generated media automation when no file path can be resolved', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.getPathForFile = vi.fn((): string | null => null);
|
||||
window.electronApi = electronApi;
|
||||
|
||||
const file = new File(['abc'], 'tegaki.png', { type: 'image/png' });
|
||||
const url = await orchestrateElectronUpload(file, ['imgur']);
|
||||
|
||||
expect(url).toBe('https://i.imgur.com/generated.png');
|
||||
expect(electronApi.automateUploadGeneratedMedia).toHaveBeenCalledWith({
|
||||
provider: 'imgur',
|
||||
fileName: 'tegaki.png',
|
||||
mimeType: 'image/png',
|
||||
bytes: [97, 98, 99],
|
||||
});
|
||||
expect(electronApi.automateUploadMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes stage and matchedSelectors when provider throws block/file-input errors', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('No file input found for imgur. Tried: input[type="file"], #upload'));
|
||||
|
||||
@@ -56,6 +56,10 @@ function resolveElectronFilePath(file: File): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fileToByteArray(file: File): Promise<number[]> {
|
||||
return Array.from(new Uint8Array(await file.arrayBuffer()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file via a single provider. Catbox uses the web API;
|
||||
* imgur/imgbb use Electron automation when available.
|
||||
@@ -66,8 +70,21 @@ async function uploadViaProvider(provider: ProviderId, file: File): Promise<stri
|
||||
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
|
||||
if (fn) {
|
||||
const filePath = resolveElectronFilePath(file);
|
||||
if (!filePath) throw new Error('File path required for Electron automation');
|
||||
const { url } = await fn({ provider, filePath });
|
||||
if (filePath) {
|
||||
const { url } = await fn({ provider, filePath });
|
||||
return url;
|
||||
}
|
||||
|
||||
const generatedFn = window.electronApi?.automateUploadGeneratedMedia;
|
||||
if (!generatedFn) {
|
||||
throw new Error('File path unavailable and automateUploadGeneratedMedia is not available');
|
||||
}
|
||||
const { url } = await generatedFn({
|
||||
provider,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
bytes: await fileToByteArray(file),
|
||||
});
|
||||
return url;
|
||||
}
|
||||
throw new Error(`Provider ${provider} requires Electron (automateUploadMedia not available)`);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TegakiGlobal } from '../tegaki-loader';
|
||||
|
||||
const SCRIPT_SELECTOR = 'script[data-tegaki-oekaki="script"]';
|
||||
|
||||
const importFreshLoader = async () => {
|
||||
vi.resetModules();
|
||||
return import('../tegaki-loader');
|
||||
};
|
||||
|
||||
const createTegaki = (): TegakiGlobal => ({
|
||||
open: vi.fn(),
|
||||
flatten: vi.fn(() => document.createElement('canvas')),
|
||||
});
|
||||
|
||||
describe('loadTegaki', () => {
|
||||
afterEach(() => {
|
||||
delete window.Tegaki;
|
||||
document.head.querySelectorAll('[data-tegaki-oekaki]').forEach((element) => element.remove());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('rejects immediately when an existing loaded script did not initialize Tegaki', async () => {
|
||||
const script = document.createElement('script');
|
||||
script.dataset.tegakiOekaki = 'script';
|
||||
Object.defineProperty(script, 'readyState', { configurable: true, value: 'complete' });
|
||||
document.head.appendChild(script);
|
||||
const { loadTegaki } = await importFreshLoader();
|
||||
|
||||
await expect(loadTegaki()).rejects.toThrow('Tegaki did not initialize');
|
||||
});
|
||||
|
||||
it('retries after a transient script load failure', async () => {
|
||||
const { loadTegaki } = await importFreshLoader();
|
||||
const firstLoad = loadTegaki();
|
||||
const firstScript = document.querySelector<HTMLScriptElement>(SCRIPT_SELECTOR);
|
||||
expect(firstScript).not.toBeNull();
|
||||
|
||||
firstScript?.dispatchEvent(new Event('error'));
|
||||
await expect(firstLoad).rejects.toThrow('Failed to load Tegaki');
|
||||
expect(document.querySelector(SCRIPT_SELECTOR)).toBeNull();
|
||||
|
||||
const secondLoad = loadTegaki();
|
||||
const secondScript = document.querySelector<HTMLScriptElement>(SCRIPT_SELECTOR);
|
||||
expect(secondScript).not.toBeNull();
|
||||
expect(secondScript).not.toBe(firstScript);
|
||||
|
||||
const tegaki = createTegaki();
|
||||
window.Tegaki = tegaki;
|
||||
secondScript?.dispatchEvent(new Event('load'));
|
||||
|
||||
await expect(secondLoad).resolves.toBe(tegaki);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const OEKAKI_WEB_DOWNLOAD_MESSAGE =
|
||||
'Auto-upload is not available on web because of CORS.\n\nDownload tegaki.png now? Upload it to some site, then paste the direct image link in the Link To File field.';
|
||||
|
||||
export const OEKAKI_WEB_WARNING_TEXT =
|
||||
'Auto-upload is not available on web because of CORS. Download tegaki.png, upload it to some site, then paste the direct image link in Link To File.';
|
||||
|
||||
export const OEKAKI_MOBILE_PORTRAIT_MESSAGE = 'Please rotate your device to draw.';
|
||||
@@ -0,0 +1,107 @@
|
||||
import { resolveAssetUrl } from '../utils/preload-utils';
|
||||
|
||||
export const TEGAKI_DRAWING_FILE_NAME = 'tegaki.png';
|
||||
|
||||
interface TegakiOpenOptions {
|
||||
width: number;
|
||||
height: number;
|
||||
saveReplay: boolean;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export interface TegakiGlobal {
|
||||
bg?: HTMLElement | null;
|
||||
open: (options: TegakiOpenOptions) => void;
|
||||
flatten: () => HTMLCanvasElement;
|
||||
destroy?: () => void;
|
||||
onOpenImageLoaded?: (this: HTMLImageElement) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Tegaki?: TegakiGlobal;
|
||||
}
|
||||
}
|
||||
|
||||
const TEGAKI_ASSET_BASE = 'vendor/tegaki/0.9.4';
|
||||
const TEGAKI_SCRIPT_URL = resolveAssetUrl(`${TEGAKI_ASSET_BASE}/tegaki.min.js`);
|
||||
const TEGAKI_STYLESHEET_URL = resolveAssetUrl(`${TEGAKI_ASSET_BASE}/tegaki.css`);
|
||||
|
||||
let tegakiLoadPromise: Promise<TegakiGlobal> | null = null;
|
||||
|
||||
const ensureTegakiStylesheet = (): void => {
|
||||
if (document.querySelector('link[data-tegaki-oekaki="stylesheet"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = TEGAKI_STYLESHEET_URL;
|
||||
link.dataset.tegakiOekaki = 'stylesheet';
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
const loadTegakiScript = (): Promise<TegakiGlobal> =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (window.Tegaki) {
|
||||
resolve(window.Tegaki);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingScript = document.querySelector<HTMLScriptElement>('script[data-tegaki-oekaki="script"]');
|
||||
if (existingScript) {
|
||||
if (window.Tegaki) {
|
||||
resolve(window.Tegaki);
|
||||
return;
|
||||
}
|
||||
if (existingScript.dataset.failed === 'true') {
|
||||
existingScript.remove();
|
||||
} else {
|
||||
const readyState = (existingScript as HTMLScriptElement & { readyState?: string }).readyState;
|
||||
if (existingScript.dataset.loaded === 'true' || readyState === 'complete' || readyState === 'loaded') {
|
||||
reject(new Error('Tegaki did not initialize'));
|
||||
return;
|
||||
}
|
||||
existingScript.addEventListener('load', () => (window.Tegaki ? resolve(window.Tegaki) : reject(new Error('Tegaki did not initialize'))), { once: true });
|
||||
existingScript.addEventListener('error', () => reject(new Error('Failed to load Tegaki')), { once: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = TEGAKI_SCRIPT_URL;
|
||||
script.async = true;
|
||||
script.dataset.tegakiOekaki = 'script';
|
||||
script.onload = () => {
|
||||
script.dataset.loaded = 'true';
|
||||
if (window.Tegaki) {
|
||||
resolve(window.Tegaki);
|
||||
return;
|
||||
}
|
||||
reject(new Error('Tegaki did not initialize'));
|
||||
};
|
||||
script.onerror = () => {
|
||||
script.dataset.failed = 'true';
|
||||
script.remove();
|
||||
reject(new Error('Failed to load Tegaki'));
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
export const loadTegaki = (): Promise<TegakiGlobal> => {
|
||||
if (window.Tegaki) {
|
||||
ensureTegakiStylesheet();
|
||||
return Promise.resolve(window.Tegaki);
|
||||
}
|
||||
|
||||
if (!tegakiLoadPromise) {
|
||||
ensureTegakiStylesheet();
|
||||
tegakiLoadPromise = loadTegakiScript().catch((error) => {
|
||||
tegakiLoadPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return tegakiLoadPromise;
|
||||
};
|
||||
@@ -82,6 +82,7 @@ const DIRECTORY_CODE_ORDER = [
|
||||
'wsg',
|
||||
'diy',
|
||||
'out',
|
||||
'i',
|
||||
'ic',
|
||||
'mu',
|
||||
'int',
|
||||
|
||||
@@ -5,6 +5,13 @@ interface PickAndUploadMediaOptions {
|
||||
providerOrder: ProviderId[];
|
||||
}
|
||||
|
||||
interface UploadGeneratedMediaOptions {
|
||||
providerOrder: ProviderId[];
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
base64: string;
|
||||
}
|
||||
|
||||
interface PickAndUploadMediaResult {
|
||||
url: string;
|
||||
fileName: string;
|
||||
@@ -14,6 +21,7 @@ interface PickAndUploadMediaResult {
|
||||
|
||||
interface FileUploaderPlugin {
|
||||
pickAndUploadMedia(options?: PickAndUploadMediaOptions): Promise<PickAndUploadMediaResult>;
|
||||
uploadGeneratedMedia(options: UploadGeneratedMediaOptions): Promise<PickAndUploadMediaResult>;
|
||||
}
|
||||
|
||||
const FileUploader = registerPlugin<FileUploaderPlugin>('FileUploader');
|
||||
|
||||
Reference in New Issue
Block a user