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.app.Activity;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.util.Base64;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.activity.result.ActivityResult;
|
import androidx.activity.result.ActivityResult;
|
||||||
@@ -31,6 +32,7 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
|
|
||||||
private static final String PROVIDER_CATBOX = "catbox";
|
private static final String PROVIDER_CATBOX = "catbox";
|
||||||
private static final long CATBOX_TIMEOUT_SEC = 30;
|
private static final long CATBOX_TIMEOUT_SEC = 30;
|
||||||
|
private static final int MAX_GENERATED_UPLOAD_BYTES = 20 * 1024 * 1024;
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
public void pickAndUploadMedia(PluginCall call) {
|
public void pickAndUploadMedia(PluginCall call) {
|
||||||
@@ -47,6 +49,62 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
startActivityForResult(call, intent, "pickFileResult");
|
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) {
|
private List<String> parseProviderOrder(PluginCall call) {
|
||||||
List<String> order = new ArrayList<>();
|
List<String> order = new ArrayList<>();
|
||||||
JSArray arr = call.getArray("providerOrder");
|
JSArray arr = call.getArray("providerOrder");
|
||||||
@@ -127,8 +185,19 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void tryProvidersSequentially(Uri fileUri, List<String> providerOrder, PluginCall call) {
|
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<>();
|
List<JSObject> attempts = new ArrayList<>();
|
||||||
StringBuilder errorSummary = new StringBuilder();
|
StringBuilder errorSummary = new StringBuilder();
|
||||||
|
String fileName = generatedFileName != null ? generatedFileName : getFileName(fileUri);
|
||||||
|
|
||||||
for (String provider : providerOrder) {
|
for (String provider : providerOrder) {
|
||||||
JSObject attempt = new JSObject();
|
JSObject attempt = new JSObject();
|
||||||
@@ -140,18 +209,21 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
if (res.success) {
|
if (res.success) {
|
||||||
attempt.put("url", res.url);
|
attempt.put("url", res.url);
|
||||||
attempts.add(attempt);
|
attempts.add(attempt);
|
||||||
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
|
resolveWithSuccess(call, res.url, fileName, provider, attempts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
attempt.put("error", res.error);
|
attempt.put("error", res.error);
|
||||||
errorSummary.append(provider).append(": ").append(res.error).append("; ");
|
errorSummary.append(provider).append(": ").append(res.error).append("; ");
|
||||||
} else if (MediaUploadRecipes.isWebViewProvider(provider)) {
|
} 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);
|
attempt.put("success", res.success);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
attempt.put("url", res.url);
|
attempt.put("url", res.url);
|
||||||
attempts.add(attempt);
|
attempts.add(attempt);
|
||||||
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
|
resolveWithSuccess(call, res.url, fileName, provider, attempts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
attempt.put("error", res.error);
|
attempt.put("error", res.error);
|
||||||
@@ -251,4 +323,54 @@ public class FileUploaderPlugin extends Plugin {
|
|||||||
return new MediaUploadResult(false, null, "Interrupted");
|
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._-]");
|
private static final Pattern UNSAFE_CHARS = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||||
|
|
||||||
public static File getFileFromUri(Context context, Uri uri) throws Exception {
|
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 fileName = getFileName(context, uri);
|
||||||
String sanitizedName = sanitizeFileName(fileName);
|
String sanitizedName = sanitizeFileName(fileName);
|
||||||
File file = new File(context.getCacheDir(), sanitizedName);
|
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,
|
* Takes only the last path segment, removes/replaces unsafe chars, enforces max length,
|
||||||
* and falls back to a UUID if the result is empty.
|
* 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) {
|
private static String sanitizeFileName(String fileName) {
|
||||||
if (fileName == null || fileName.isEmpty()) {
|
if (fileName == null || fileName.isEmpty()) {
|
||||||
return UUID.randomUUID().toString();
|
return UUID.randomUUID().toString();
|
||||||
@@ -82,4 +108,4 @@ public class FileUtils {
|
|||||||
}
|
}
|
||||||
return basename;
|
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
|
// NOTE: eventually should probably fake sec-ch-ua header as well
|
||||||
const fakeUserAgent = createFakeUserAgent();
|
const fakeUserAgent = createFakeUserAgent();
|
||||||
const realUserAgent = `5chan/${packageJson.version}`;
|
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
|
// add right click menu
|
||||||
contextMenu({
|
contextMenu({
|
||||||
@@ -391,6 +417,20 @@ ipcMain.handle('automate-upload-media', async (event, options) => {
|
|||||||
return automateUploadMedia({ provider, filePath });
|
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) => {
|
ipcMain.handle('download-and-install-update', async (event, options) => {
|
||||||
const { url, fileName } = options || {};
|
const { url, fileName } = options || {};
|
||||||
return downloadAndInstallUpdate({ url, fileName });
|
return downloadAndInstallUpdate({ url, fileName });
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ contextBridge.exposeInMainWorld('electronApi', {
|
|||||||
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
copyToClipboard: (text) => ipcRenderer.invoke('copy-to-clipboard', text),
|
||||||
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
getPlatform: () => ipcRenderer.invoke('get-platform'),
|
||||||
automateUploadMedia: (options) => ipcRenderer.invoke('automate-upload-media', options),
|
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),
|
downloadAndInstallUpdate: (options) => ipcRenderer.invoke('download-and-install-update', options),
|
||||||
getPathForFile: (file) => {
|
getPathForFile: (file) => {
|
||||||
try {
|
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,d09GRgABAAAAAAw4AAsAAAAAEpQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAADsAAABUIIslek9TLzIAAAFEAAAAQwAAAFY+IFLIY21hcAAAAYgAAAC+AAACptXj0XhnbHlmAAACSAAABtcAAAnIF+/SeGhlYWQAAAkgAAAAMwAAADYXAPWXaGhlYQAACVQAAAAgAAAAJAd1A5tobXR4AAAJdAAAAEAAAABcShf/3GxvY2EAAAm0AAAAMAAAADAZpBxobWF4cAAACeQAAAAfAAAAIAExAGtuYW1lAAAKBAAAAX4AAAK17cxnR3Bvc3QAAAuEAAAAswAAAPr4ELHkeJxjYGRgYOBiMGCwY2BycfMJYeDLSSzJY5BiYGGAAJA8MpsxJzM9kYEDxgPKsYBpDiBmg4gCACY7BUgAeJxjYGS2YJzAwMrAwFTFtIeBgaEHQjM+YDBkZAKKMrAyM2AFAWmuKQwOLxg+/mYO+p/FEMUcwTANKMwIkgMA5OUMbwB4nO2SSXLCQBAEc0BsBi8sVvgRhhfxIE5+Z1115gDVUvEL90Qqenq0RWcDC2Bufk0H7Y9Gxc3VNtbnvI31jqv3a68Z6Dich/vj4YxXNkbz+cWrspmf7fyFJStXN37Plh3vfPDJF3sOHDnxTe+bl/zHri7tJ7u+ejtRJhTG7ocyp1D2FMqqgruPgj2gYCMo2A0KZVvBvlCov1OwQxRsEwV7RcGGUbBrFGwdBfv3JE14EjxJE54JhvsE/RM9XjzzAAB4nKVWXWgc1xW+597dmd2Z1ezsaHZmtZE265nZGWkt1uruaMaswtpxgyWCAomiKrFZ60FNTVuFEtuKHkLqh0JMA25cigmBpCk1GxEc8uMmqGAMJokfilvylNp6KsZJiQKpGoIfmiDN9tzZFU6bt1agc885e++5c88533cvAUK6/6Qf0zPEIaSiZ0Gw9oHIhesfAI+LoH4vhFwY+LNp0I+zs+q42umgmFX5qN61s9lOJ/uUwZXXXst+d2K2xicQQvi+XzONycRGI68Lno1CtC03nEThTfpB2EAxBXXDzDfqBtPKxmbZWDbKsGmWAI2SuYwKN/7IvZ8b6M1/3vcaZT493ocQpsE/SLa/z55+9D29mDtH42h0bTdOLwAuJRS/8adsiz1OhohLyKACIjNDFxfXDV2wLU80Ky3ALKXBhCAMPNdzReFlJZ88lV5njl+pFMXUugRHFD15//vwm+jlq/DcvpRQHBqvTUzCWFJXorX0ekosViq+w9bTp5J5pfM+XIqeuQrHx4KJ2nihKIhxrj5j63SM6ISksUIe9OpipqFXF/ZGtCSVpGhJlo/hCKMwKg9n2jKci34oy/BbuSS1ZTnaQLfclofJbl7W6WEes4IF99LQq3rIA8cx27gs2og25GH5GEZ5FUcUy21JgrHopiTx3+FViW/LQxLo7nQv0UP0Q1LEbGF+zRwWrtdSboilzNlcCzR4XpRgGdvpD7IlvaukJXP53ZRwRJToh1IqKo0O3hgYuJEzdPiR8KRoxHXYYBn6JZGxEgRKMAIiiCF2JYSuVwPby4JhGib9qFzajv62VQdrO4iOFmjdqGUUQxvWChn6evmgqm5Ht/wvobyd2dlfGK8bRVp4WNUKhV4/XmQ5Noh78By7kzk/iNOQ2LlnQNMG6N8H4KHoCVHKskDJpFDT4xR2/9V9h73EMoifKgkxUN0QmUKtGuwDIwjrge9aQl43FcAP9Vz8XjfAE5ieUKm3wK+BpYBeAiq9dSvVpod/3XnhME3MXWgOj442q9LEmYPHf5ZIdbRE6la0mTjULD8Bf5p7cXb2/Np5FCyztH/nPc2fmPC1kjA3i0uqw7rabCWkxdYzrcXx/UtRtRxk/jL+olMdrjqxiPN5iR1F7GXJvfjFOWxmjgo8cQOrlvwvmx3R1eiTrA6GAiNZI5L/w6SfbW8phqEwjctv6T38nKYTrEVyMcpLDPFnIX5aFPmEIV6sLCg0rxuN+iT2naW02zNrn67NtNsK1ao0IaiJ0XLZsQrNKgtWr6+8Mney0zk598rK9dVU9FRCEoRmbeJ4vWA9sPhSn1PepFNMISNoBEYeD4JQ8XDYC73zxHSSC0JqyYoiRytJaijT8Vm4pEnGZCG7fV1JZeBsUoieu3uy6EwyyTmhu909zTbYg2SA5y4MSlQURCH0wxaAQbjOaQALjYRAVt3FuYyz8MJ8QvAL7Mli1Fl1F1IprVS21NTB0VW4dtGSHl505rVMMSHcd5G+XoweWHEPCYpVKquCNO+uwAJRY67cxHoxIhJetUFikntwf5t4ZC/ZRxrYeVPkACGmnWsM/h//Nv7lcpO53GO53OO2fTiXczTN1rTHbJubtj3tOL5t/8T30Y+KyLTtrf/9H6EWc+wmm0NNQwx9jzTJ98ks+QFZxBr6LhZOGIHGHkvQDY6lpI14wcIa2JkBYrQGDKHmt6BeAl0B6OFNN8TdBd6u0thVBncVWK8Ufz5U+QbuxATs7Hz1TWOhUSmi7dQX3tk7U63OLEyjoNNFp9KoVC4MVfjw13h65ULR4Rx/oWcxHc3iFe7xnRbGiR7ERUOwztVMdWaeR0NBT/AQvnOgN/MK3zjW0B0937NiLu1232LHWJYznim4YY+OzD4riTFDCeKQpIIiY68yLpHiV2SFJaexiZGlUhls8CScjRV+PaygH34lJHexwn6PWBmKsWLexYrXuwQQPkHIfikpoKajU0nK4SLzTZTvwORUrPDr4Sz6+aa9+wV+TH+B2CeDeKMY/O3Qf1iEwVdKXXXdy5ddV51Qb6vqR/Skqjrj1z4Yd1T1C7Wm3ohzsIViHmMgAgZNI36deC5/liB/LOGy27jacS5fdhy1Tk/ydV/wMB9cwzAcq1H360Qd7yOGeCGVcI8IhoCpi9nNDfCQnN/gzpXo0SvsdlpQooxRjh7NW1JKUOBOvgxv5p2DN2/SI4PV7M61gqHjSO8rGLtvA963DhnDr+y9jfrvJ0C+Eb2Qc9q3X1Bp8CDP3vP9001BeFpQhGb0dn7kxDlnOg+PTPVdz/rnd34H9y9HV1lp8rz/bBOdTwvCFE6dds6dGMGpfVfztL/953jmMvk3IB776AB4nGNgZGBgAOJPRx0y4vltvjJwM78AijDcfHDjB4z+/+5/FosRcwSQy8HABBIFALlhD/oAeJxjYGRgYA76n8XAwKL//93/VyxGDEARFCAOAJQMBhd4nGN+wcDALIiEXyAwk/X/v8w8UPGJ/38wRwLFDEDiQP6h/3/gahf8/8+8gIGBMRSIQ/6/Y9H//w8kDgBfpxcoAAAAAAA8AHAAjgDSAPwBJgFSAYQBoAH6Ai4CZgKYAtIDQAPMA/wELAROBG4EnATkeJxjYGRgYBBniGcQYAABJiDmAkIGhv9gPgMAFW4BnQB4nHWQzUrDQBSFz9jaYisuLLgeN2IR0x9w0bopFlpXCl0UxIWMdZqkppkymRb6Cr6DD+EL+SyeJoMUwQwz+e65597cCYBTfEOgeG64CxaoMCr4AFXcei5Rv/NcJt97PkQdD54rXE+ea7jCi+c6GvhgB1E+YrTAp2eBY1H1fIAT0fBcon7uuUy+9nyIM9HzXKH+6LmGqXj2XMeF+Bqa1dbGYeTk5bApu+1OT75upaEUpyqRau0iYzM5kHOTOp0kJpiZpdOheo8nOlwnyhZBcU61zWKTyk7QLoSxTrVVTr/tumabsOvcXM6tWcqR7ydX1iz0zAWRc6t+q7X/HQxhsMIWFjFCRHCQuKTa5LuLNjrokV7pkHQWrhgpFBIqCmtWRHkmYzzgnjNKqWo6EnKAGc9lroSseGf9JOc184qV+5l9npJ3feO8o+QsASfad4zJae5Suf72O2uGDV1dqo4T7aay+RQSoz/zSd5/l1tQmVEP8r/gqPbR4vrnPj+rn3wZAAB4nG2NWVLDMBBE1YktLySBbIRL+FCyMiYqFEk1GuHi9iT4l/fTS1VXq5Va6NX/XLDCGhVqaDRo0aHHCzbYYodXvGGPA4444Yx3XPChtDXBkq+SL7m+u1DyOlHornEOQ3y4pqQ/7T19kx+evX5E654T86PHYr9I6mRKpmr0hVuJQxbD0s9GiG30kSuJgVrjeOSSb81ksgzTvOg4d54mWU7Yfd4Wq4lNJm6SSyRCSv0C/DA9cAA=') 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',
|
'wsg',
|
||||||
'diy',
|
'diy',
|
||||||
'out',
|
'out',
|
||||||
|
'i',
|
||||||
'ic',
|
'ic',
|
||||||
'mu',
|
'mu',
|
||||||
'int',
|
'int',
|
||||||
@@ -259,7 +260,10 @@ const loadDirectoriesSource = async () => {
|
|||||||
throw new Error('Invalid GitHub directory listing');
|
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 defaults = normalizeDirectoryDefaultsData(await fetchJson(`${GITHUB_RAW_BASE_URL}/${DEFAULTS_FILE_NAME}`));
|
||||||
const directories = await Promise.all(
|
const directories = await Promise.all(
|
||||||
fileNames.map(async (fileName) => {
|
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 { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import PostForm, { LinkTypePreviewer } from '../post-form';
|
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';
|
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(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,
|
editedComment: undefined as { commentModeration?: { archived?: boolean }; deleted?: boolean; locked?: boolean; postCid?: string; removed?: boolean } | undefined,
|
||||||
gifFrameStatus: 'idle' as 'idle' | 'ready',
|
gifFrameStatus: 'idle' as 'idle' | 'ready',
|
||||||
handleUploadMock: vi.fn(),
|
handleUploadMock: vi.fn(),
|
||||||
|
uploadFileMock: vi.fn(),
|
||||||
isOffline: false,
|
isOffline: false,
|
||||||
isOnlineStatusLoading: false,
|
isOnlineStatusLoading: false,
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
isResolvingExternalQuotes: false,
|
isResolvingExternalQuotes: false,
|
||||||
|
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
offlineTitle: 'offline board',
|
offlineTitle: 'offline board',
|
||||||
postIndex: undefined as number | undefined,
|
postIndex: undefined as number | undefined,
|
||||||
@@ -259,6 +262,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
|
|||||||
testState.uploadComplete = onUploadComplete;
|
testState.uploadComplete = onUploadComplete;
|
||||||
return {
|
return {
|
||||||
handleUpload: testState.handleUploadMock,
|
handleUpload: testState.handleUploadMock,
|
||||||
|
uploadFile: testState.uploadFileMock,
|
||||||
isUploading: testState.isUploading,
|
isUploading: testState.isUploading,
|
||||||
uploadedFileName: testState.uploadedFileName,
|
uploadedFileName: testState.uploadedFileName,
|
||||||
};
|
};
|
||||||
@@ -286,8 +290,9 @@ vi.mock('../../../lib/utils/media-utils', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||||
|
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
||||||
getShowUploadControls: () => testState.showUploadControls,
|
getShowUploadControls: () => testState.showUploadControls,
|
||||||
isWebRuntime: () => true,
|
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||||
@@ -438,6 +443,7 @@ describe('PostForm', () => {
|
|||||||
testState.isOnlineStatusLoading = false;
|
testState.isOnlineStatusLoading = false;
|
||||||
testState.isUploading = false;
|
testState.isUploading = false;
|
||||||
testState.isResolvingExternalQuotes = false;
|
testState.isResolvingExternalQuotes = false;
|
||||||
|
testState.mediaHostingRuntime = 'web';
|
||||||
testState.offlineTitle = 'offline board';
|
testState.offlineTitle = 'offline board';
|
||||||
testState.postIndex = undefined;
|
testState.postIndex = undefined;
|
||||||
testState.publishedPostOptions = undefined;
|
testState.publishedPostOptions = undefined;
|
||||||
@@ -456,6 +462,7 @@ describe('PostForm', () => {
|
|||||||
'traditional-games.bso': { address: 'traditional-games.bso' },
|
'traditional-games.bso': { address: 'traditional-games.bso' },
|
||||||
};
|
};
|
||||||
testState.handleUploadMock.mockReset();
|
testState.handleUploadMock.mockReset();
|
||||||
|
testState.uploadFileMock.mockReset();
|
||||||
testState.navigateMock.mockReset();
|
testState.navigateMock.mockReset();
|
||||||
testState.publishPostMock.mockReset();
|
testState.publishPostMock.mockReset();
|
||||||
testState.publishReplyMock.mockReset();
|
testState.publishReplyMock.mockReset();
|
||||||
@@ -573,6 +580,43 @@ describe('PostForm', () => {
|
|||||||
expect(testState.setPublishPostOptionsMock).toHaveBeenCalledWith({ communityAddress: 'music-posting.eth' });
|
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 () => {
|
it('drops stale thread content when board navigation remounts the form before a link-only post', async () => {
|
||||||
await renderNavigablePostForm('/mu');
|
await renderNavigablePostForm('/mu');
|
||||||
await clickByText(container, 'start_new_thread');
|
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 usePublishReply from '../../hooks/use-publish-reply';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||||
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
|
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 { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||||
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||||
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
import BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
||||||
import LoadingEllipsis from '../loading-ellipsis';
|
import LoadingEllipsis from '../loading-ellipsis';
|
||||||
|
import OekakiDrawingControls from '../oekaki-drawing-controls';
|
||||||
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
||||||
import styles from './post-form.module.css';
|
import styles from './post-form.module.css';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
@@ -141,6 +143,7 @@ interface PostFormFieldsProps {
|
|||||||
isUploading: boolean;
|
isUploading: boolean;
|
||||||
uploadedFileName: string | null | undefined;
|
uploadedFileName: string | null | undefined;
|
||||||
showUploadControls: boolean;
|
showUploadControls: boolean;
|
||||||
|
showOekakiControls: boolean;
|
||||||
showSpoilerForPost: boolean;
|
showSpoilerForPost: boolean;
|
||||||
showSpoilerForReply: boolean;
|
showSpoilerForReply: boolean;
|
||||||
isInAllView: boolean;
|
isInAllView: boolean;
|
||||||
@@ -158,6 +161,8 @@ interface PostFormFieldsProps {
|
|||||||
onPublishReply: () => void;
|
onPublishReply: () => void;
|
||||||
onPublishPost: () => void;
|
onPublishPost: () => void;
|
||||||
handleUpload: () => void;
|
handleUpload: () => void;
|
||||||
|
uploadFile: ReturnType<typeof useFileUpload>['uploadFile'];
|
||||||
|
onOekakiClearUploadedUrl: (url: string) => void;
|
||||||
disableReplyPublish: boolean;
|
disableReplyPublish: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +190,7 @@ const PostFormFields = ({
|
|||||||
isUploading,
|
isUploading,
|
||||||
uploadedFileName,
|
uploadedFileName,
|
||||||
showUploadControls,
|
showUploadControls,
|
||||||
|
showOekakiControls,
|
||||||
showSpoilerForPost,
|
showSpoilerForPost,
|
||||||
showSpoilerForReply,
|
showSpoilerForReply,
|
||||||
isInAllView,
|
isInAllView,
|
||||||
@@ -202,6 +208,8 @@ const PostFormFields = ({
|
|||||||
onPublishReply,
|
onPublishReply,
|
||||||
onPublishPost,
|
onPublishPost,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
|
uploadFile,
|
||||||
|
onOekakiClearUploadedUrl,
|
||||||
disableReplyPublish,
|
disableReplyPublish,
|
||||||
}: PostFormFieldsProps) => (
|
}: PostFormFieldsProps) => (
|
||||||
<>
|
<>
|
||||||
@@ -365,6 +373,14 @@ const PostFormFields = ({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
{showOekakiControls && (
|
||||||
|
<tr>
|
||||||
|
<td>Draw</td>
|
||||||
|
<td>
|
||||||
|
<OekakiDrawingControls disabled={isUploading} uploadFile={uploadFile} onClearUploadedUrl={onOekakiClearUploadedUrl} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||||
<tr className={styles.spoilerButton}>
|
<tr className={styles.spoilerButton}>
|
||||||
<td>{capitalize(t('spoiler'))}</td>
|
<td>{capitalize(t('spoiler'))}</td>
|
||||||
@@ -424,6 +440,7 @@ const PostFormFields = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
{showOekakiControls && isWebRuntime() ? <li>{OEKAKI_WEB_WARNING_TEXT}</li> : null}
|
||||||
</ul>
|
</ul>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -455,6 +472,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const nonokoRedirectPathRef = useRef<string | null>(null);
|
const nonokoRedirectPathRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isInPostView = isPostPageView(location.pathname, params);
|
||||||
const isInAllView = isAllView(location.pathname);
|
const isInAllView = isAllView(location.pathname);
|
||||||
const isInModView = isModView(location.pathname);
|
const isInModView = isModView(location.pathname);
|
||||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||||
@@ -466,6 +484,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
|
const showSpoilerForPost = directoryEntry?.features?.noSpoilers !== true;
|
||||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
||||||
|
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
|
||||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||||
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
||||||
@@ -600,7 +619,6 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
|
}, [postIndex, pendingPostBoardPath, resetFields, resetPublishPostOptions, navigate]);
|
||||||
|
|
||||||
// in post page, publish a reply to the post
|
// in post page, publish a reply to the post
|
||||||
const isInPostView = isPostPageView(location.pathname, params);
|
|
||||||
const cid = params?.commentCid || '';
|
const cid = params?.commentCid || '';
|
||||||
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
const { isResolvingExternalQuotes, publishReply, publishReplyError, publishReplyStateMessage, resetPublishReplyOptions, replyIndex, setPublishReplyOptions } =
|
||||||
usePublishReply({ cid, communityAddress, postCid });
|
usePublishReply({ cid, communityAddress, postCid });
|
||||||
@@ -707,7 +725,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
}
|
}
|
||||||
}, [replyIndex, closeForm, navigate, resetFields]);
|
}, [replyIndex, closeForm, navigate, resetFields]);
|
||||||
|
|
||||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
||||||
onUploadComplete: (uploadedUrl: string) => {
|
onUploadComplete: (uploadedUrl: string) => {
|
||||||
if (uploadedUrl) {
|
if (uploadedUrl) {
|
||||||
setUrl(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 uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||||
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
||||||
|
|
||||||
@@ -765,6 +798,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
isUploading={isUploading}
|
isUploading={isUploading}
|
||||||
uploadedFileName={uploadedFileName}
|
uploadedFileName={uploadedFileName}
|
||||||
showUploadControls={showUploadControls}
|
showUploadControls={showUploadControls}
|
||||||
|
showOekakiControls={showOekakiControls}
|
||||||
showSpoilerForPost={showSpoilerForPost}
|
showSpoilerForPost={showSpoilerForPost}
|
||||||
showSpoilerForReply={showSpoilerForReply}
|
showSpoilerForReply={showSpoilerForReply}
|
||||||
isInAllView={isInAllView}
|
isInAllView={isInAllView}
|
||||||
@@ -782,6 +816,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
|||||||
onPublishReply={onPublishReply}
|
onPublishReply={onPublishReply}
|
||||||
onPublishPost={onPublishPost}
|
onPublishPost={onPublishPost}
|
||||||
handleUpload={handleUpload}
|
handleUpload={handleUpload}
|
||||||
|
uploadFile={uploadFile}
|
||||||
|
onOekakiClearUploadedUrl={handleOekakiClearUploadedUrl}
|
||||||
disableReplyPublish={isResolvingExternalQuotes}
|
disableReplyPublish={isResolvingExternalQuotes}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
|||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import ReplyModal from '../reply-modal';
|
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';
|
import { POST_OPTIONS_VALIDATION_DELAY_MS } from '../../../lib/utils/post-options-utils';
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(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 }>,
|
} as Record<string, { address: string; directoryCode?: string; features?: Record<string, unknown>; title?: string }>,
|
||||||
handleUploadMock: vi.fn(),
|
handleUploadMock: vi.fn(),
|
||||||
|
uploadFileMock: vi.fn(),
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
|
mediaHostingRuntime: 'web' as 'web' | 'android' | 'electron',
|
||||||
isResolvingExternalQuotes: false,
|
isResolvingExternalQuotes: false,
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
navigateMock: vi.fn(),
|
navigateMock: vi.fn(),
|
||||||
@@ -127,8 +130,9 @@ vi.mock('../../../stores/use-reply-modal-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({
|
||||||
|
getMediaHostingRuntime: () => testState.mediaHostingRuntime,
|
||||||
getShowUploadControls: () => testState.showUploadControls,
|
getShowUploadControls: () => testState.showUploadControls,
|
||||||
isWebRuntime: () => true,
|
isWebRuntime: () => testState.mediaHostingRuntime === 'web',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
vi.mock('../../../stores/use-media-hosting-store', () => ({
|
||||||
@@ -185,6 +189,7 @@ vi.mock('../../../hooks/use-file-upload', () => ({
|
|||||||
testState.uploadComplete = onUploadComplete;
|
testState.uploadComplete = onUploadComplete;
|
||||||
return {
|
return {
|
||||||
handleUpload: testState.handleUploadMock,
|
handleUpload: testState.handleUploadMock,
|
||||||
|
uploadFile: testState.uploadFileMock,
|
||||||
isUploading: testState.isUploading,
|
isUploading: testState.isUploading,
|
||||||
uploadedFileName: testState.uploadedFileName,
|
uploadedFileName: testState.uploadedFileName,
|
||||||
};
|
};
|
||||||
@@ -352,6 +357,7 @@ describe('ReplyModal', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
testState.handleUploadMock.mockReset();
|
testState.handleUploadMock.mockReset();
|
||||||
|
testState.uploadFileMock.mockReset();
|
||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
testState.isResolvingExternalQuotes = false;
|
testState.isResolvingExternalQuotes = false;
|
||||||
testState.isUploading = false;
|
testState.isUploading = false;
|
||||||
@@ -398,6 +404,7 @@ describe('ReplyModal', () => {
|
|||||||
testState.uploadComplete = undefined;
|
testState.uploadComplete = undefined;
|
||||||
testState.uploadedFileName = null;
|
testState.uploadedFileName = null;
|
||||||
testState.uploadMode = 'always';
|
testState.uploadMode = 'always';
|
||||||
|
testState.mediaHostingRuntime = 'web';
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
root = createRoot(container);
|
root = createRoot(container);
|
||||||
@@ -437,6 +444,30 @@ describe('ReplyModal', () => {
|
|||||||
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
|
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 () => {
|
it('shows a flag selector on flag boards and publishes the default geographic request', async () => {
|
||||||
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
|
await renderReplyModal('/pol/thread/post-1', 'politically-incorrect.bso');
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,32 @@
|
|||||||
width: 130px;
|
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 {
|
.offlineBoard {
|
||||||
width: 292px;
|
width: 292px;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ import usePublishReply from '../../hooks/use-publish-reply';
|
|||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||||
import { useCommunityField } from '../../hooks/use-stable-community';
|
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 BbcodeEditorToolbar, { BbcodePreview } from '../bbcode-editor-toolbar/bbcode-editor-toolbar';
|
||||||
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
|
||||||
import LoadingEllipsis from '../loading-ellipsis';
|
import LoadingEllipsis from '../loading-ellipsis';
|
||||||
|
import OekakiDrawingControls from '../oekaki-drawing-controls';
|
||||||
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
import PostOptionsErrorMessage from '../post-options-error-message/post-options-error-message';
|
||||||
import styles from './reply-modal.module.css';
|
import styles from './reply-modal.module.css';
|
||||||
import capitalize from 'lodash/capitalize';
|
import capitalize from 'lodash/capitalize';
|
||||||
@@ -63,6 +65,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
|
const directoryEntry = findDirectoryByAddress(directories, communityAddress);
|
||||||
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
|
||||||
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
const postOptionsDirectoryCode = getPostOptionsDirectoryCode(directoryEntry, location.pathname);
|
||||||
|
const showOekakiControls = postOptionsDirectoryCode === 'i' || directoryEntry?.directoryCode === 'i';
|
||||||
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
const requirePostLinkIsMediaFeature = directoryEntry?.features?.requirePostLinkIsMedia;
|
||||||
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
const requirePostLinkIsMedia = requirePostLinkIsMediaFeature === true || (requirePostLinkIsMediaFeature === undefined && (isInAllView || isInSubscriptionsView));
|
||||||
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
const flagOptions = getCommentFlagOptionsForDirectory(directoryEntry);
|
||||||
@@ -429,7 +432,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
checkContentLengthRef.current(publishContent, t);
|
checkContentLengthRef.current(publishContent, t);
|
||||||
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
}, [showReplyModal, quoteInsertRequestId, quoteInsertNumber, quoteInsertSelectedText, postOptionsDirectoryCode, setPublishReplyOptions, t]);
|
||||||
|
|
||||||
const { isUploading, uploadedFileName, handleUpload } = useFileUpload({
|
const { isUploading, uploadedFileName, handleUpload, uploadFile } = useFileUpload({
|
||||||
onUploadComplete: (uploadedUrl: string) => {
|
onUploadComplete: (uploadedUrl: string) => {
|
||||||
if (uploadedUrl) {
|
if (uploadedUrl) {
|
||||||
setUrl(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 uploadMode = useMediaHostingStore((state) => state.uploadMode);
|
||||||
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
const showUploadControls = getShowUploadControls(uploadMode, isWebRuntime());
|
||||||
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
|
const displayedFileName = getPublishURLFilename(url) || uploadedFileName;
|
||||||
@@ -546,6 +557,13 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{flagOptions.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"title": "5chan directories",
|
"title": "5chan directories",
|
||||||
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
|
"description": "Directory assignments built from per-directory candidate lists in https://github.com/bitsocialnet/lists/tree/master/5chan-directories",
|
||||||
"createdAt": 1779182014,
|
"createdAt": 1779182014,
|
||||||
"updatedAt": 1779279785,
|
"updatedAt": 1780054375,
|
||||||
"directories": [
|
"directories": [
|
||||||
{
|
{
|
||||||
"directoryCode": "a",
|
"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",
|
"directoryCode": "ic",
|
||||||
"title": "/ic/ - Artwork/Critique",
|
"title": "/ic/ - Artwork/Critique",
|
||||||
|
|||||||
Vendored
+6
@@ -14,6 +14,12 @@ declare global {
|
|||||||
copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>;
|
copyToClipboard: (text: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>;
|
getPlatform: () => Promise<{ platform: NodeJS.Platform; arch: string; version: string }>;
|
||||||
automateUploadMedia: (options: { provider: ProviderId; filePath: string }) => Promise<{ url: string; provider: ProviderId }>;
|
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>;
|
downloadAndInstallUpdate?: (options: { url: string; fileName: string }) => Promise<void>;
|
||||||
getPathForFile?: (file: File) => string | null;
|
getPathForFile?: (file: File) => string | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ vi.mock('@capacitor/core', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../plugins/file-uploader', () => ({
|
vi.mock('../../plugins/file-uploader', () => ({
|
||||||
default: { pickAndUploadMedia: vi.fn() },
|
default: { pickAndUploadMedia: vi.fn(), uploadGeneratedMedia: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../lib/utils/catbox-utils', () => ({
|
vi.mock('../../lib/utils/catbox-utils', () => ({
|
||||||
@@ -211,6 +211,77 @@ describe('useFileUpload', () => {
|
|||||||
expect(hook().isUploading).toBe(false);
|
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 () => {
|
it('silently ignores file selection cancellation', async () => {
|
||||||
uploadModeRef.value = 'preferred';
|
uploadModeRef.value = 'preferred';
|
||||||
preferredProviderRef.value = 'catbox';
|
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 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'];
|
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'imgbb'];
|
||||||
|
|
||||||
@@ -88,6 +89,24 @@ interface UseFileUploadOptions {
|
|||||||
onUploadComplete: (url: string, fileName: string) => void;
|
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> {
|
function selectFileViaInput(): Promise<File | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
@@ -151,32 +170,97 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [uploadedFileName, setUploadedFileName] = useState<string | null>(null);
|
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 () => {
|
const handleUpload = useCallback(async () => {
|
||||||
if (uploadMode === 'none') return;
|
if (uploadMode === 'none') return;
|
||||||
|
|
||||||
const runtime = getMediaHostingRuntime();
|
|
||||||
try {
|
try {
|
||||||
setIsUploading(true);
|
setIsUploading(true);
|
||||||
setUploadedFileName(null);
|
setUploadedFileName(null);
|
||||||
const supportedOrder = getProviderOrder({ mode: uploadMode, preferredProvider, runtime });
|
const { runtime, order } = await getAvailableProviderOrder();
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (runtime === 'android') {
|
if (runtime === 'android') {
|
||||||
const result = await FileUploader.pickAndUploadMedia({ providerOrder: order });
|
const result = await FileUploader.pickAndUploadMedia({ providerOrder: order });
|
||||||
if (result.url) {
|
if (result.url) {
|
||||||
@@ -186,7 +270,7 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isElectronRuntime()) {
|
if (runtime === 'electron' || isElectronRuntime()) {
|
||||||
const file = await selectFileViaInput();
|
const file = await selectFileViaInput();
|
||||||
if (!file) {
|
if (!file) {
|
||||||
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
|
throw new Error(FILE_SELECTION_CANCELLED_ERROR);
|
||||||
@@ -198,27 +282,18 @@ export function useFileUpload(options: UseFileUploadOptions) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.alert(t('upload_not_supported_web'));
|
throw new Error(WEB_UPLOAD_NOT_SUPPORTED_ERROR);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
handleUploadError(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}`);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
}, [onUploadComplete, t, uploadMode, preferredProvider]);
|
}, [getAvailableProviderOrder, handleUploadError, onUploadComplete, uploadMode]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isUploading,
|
isUploading,
|
||||||
uploadedFileName,
|
uploadedFileName,
|
||||||
handleUpload,
|
handleUpload,
|
||||||
|
uploadFile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ function createElectronApiMock() {
|
|||||||
copyToClipboard: vi.fn(async () => ({ success: true })),
|
copyToClipboard: vi.fn(async () => ({ success: true })),
|
||||||
getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })),
|
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 })),
|
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'),
|
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 () => {
|
it('fails with provider attempt details if no file path can be resolved', async () => {
|
||||||
const electronApi = createElectronApiMock();
|
const electronApi = createElectronApiMock();
|
||||||
electronApi.getPathForFile = vi.fn((): string | null => null);
|
electronApi.getPathForFile = vi.fn((): string | null => null);
|
||||||
|
electronApi.automateUploadGeneratedMedia = undefined as unknown as typeof electronApi.automateUploadGeneratedMedia;
|
||||||
window.electronApi = electronApi;
|
window.electronApi = electronApi;
|
||||||
|
|
||||||
const file = new File(['z'], 'z.png', { type: 'image/png' });
|
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.message).toBe('All providers failed');
|
||||||
expect(typedError.attempts?.[0]?.provider).toBe('imgur');
|
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]?.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||||
expect(typedError.attempts?.[0]?.stage).toBeDefined();
|
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 () => {
|
it('includes stage and matchedSelectors when provider throws block/file-input errors', async () => {
|
||||||
const electronApi = createElectronApiMock();
|
const electronApi = createElectronApiMock();
|
||||||
electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('No file input found for imgur. Tried: input[type="file"], #upload'));
|
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;
|
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;
|
* Uploads a file via a single provider. Catbox uses the web API;
|
||||||
* imgur/imgbb use Electron automation when available.
|
* 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;
|
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
|
||||||
if (fn) {
|
if (fn) {
|
||||||
const filePath = resolveElectronFilePath(file);
|
const filePath = resolveElectronFilePath(file);
|
||||||
if (!filePath) throw new Error('File path required for Electron automation');
|
if (filePath) {
|
||||||
const { url } = await fn({ provider, 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;
|
return url;
|
||||||
}
|
}
|
||||||
throw new Error(`Provider ${provider} requires Electron (automateUploadMedia not available)`);
|
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',
|
'wsg',
|
||||||
'diy',
|
'diy',
|
||||||
'out',
|
'out',
|
||||||
|
'i',
|
||||||
'ic',
|
'ic',
|
||||||
'mu',
|
'mu',
|
||||||
'int',
|
'int',
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ interface PickAndUploadMediaOptions {
|
|||||||
providerOrder: ProviderId[];
|
providerOrder: ProviderId[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UploadGeneratedMediaOptions {
|
||||||
|
providerOrder: ProviderId[];
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
base64: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface PickAndUploadMediaResult {
|
interface PickAndUploadMediaResult {
|
||||||
url: string;
|
url: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -14,6 +21,7 @@ interface PickAndUploadMediaResult {
|
|||||||
|
|
||||||
interface FileUploaderPlugin {
|
interface FileUploaderPlugin {
|
||||||
pickAndUploadMedia(options?: PickAndUploadMediaOptions): Promise<PickAndUploadMediaResult>;
|
pickAndUploadMedia(options?: PickAndUploadMediaOptions): Promise<PickAndUploadMediaResult>;
|
||||||
|
uploadGeneratedMedia(options: UploadGeneratedMediaOptions): Promise<PickAndUploadMediaResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileUploader = registerPlugin<FileUploaderPlugin>('FileUploader');
|
const FileUploader = registerPlugin<FileUploaderPlugin>('FileUploader');
|
||||||
|
|||||||
Reference in New Issue
Block a user