Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-05-30 15:39:04 +07:00
28 changed files with 2134 additions and 49 deletions
@@ -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;
} }
} }
+40
View File
@@ -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 });
+1
View File
@@ -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 {
+19
View File
@@ -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
View File
@@ -0,0 +1,669 @@
/*! Contains fonts from Font Awesome (Copyright (C) 2016 by Dave Gandy), Entypo (Copyright (C) 2012 by Daniel Bruce) */
@font-face {
font-family: tegaki;
src: url('data:application/octet-stream;base64,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') format('woff');
font-weight: normal;
font-style: normal;
}
.tegaki-icon:before {
font-family: tegaki;
font-style: normal;
font-weight: normal;
speak: none;
display: inline-block;
text-align: center;
font-variant: normal;
text-transform: none;
line-height: 1em;
}
.tegaki-cancel:before { content: '\e800'; } /* '' */
.tegaki-plus:before { content: '\e801'; } /* '' */
.tegaki-minus:before { content: '\e802'; } /* '' */
.tegaki-pen:before { content: '\e803'; } /* '' */
.tegaki-down-open:before { content: '\e804'; } /* '' */
.tegaki-up-open:before { content: '\e805'; } /* '' */
.tegaki-level-down:before { content: '\e806'; } /* '' */
.tegaki-pencil:before { content: '\e807'; } /* '' */
.tegaki-play:before { content: '\e808'; } /* '' */
.tegaki-bucket:before { content: '\e809'; } /* '' */
.tegaki-pause:before { content: '\e80a'; } /* '' */
.tegaki-blur:before { content: '\e80b'; } /* '' */
.tegaki-to-start:before { content: '\e80c'; } /* '' */
.tegaki-watercolor:before { content: '\e80d'; } /* '' */
.tegaki-tone:before { content: '\e80e'; } /* '' */
.tegaki-airbrush:before { content: '\e80f'; } /* '' */
.tegaki-fast-fw:before { content: '\e810'; } /* '' */
.tegaki-fast-bw:before { content: '\e811'; } /* '' */
.tegaki-left-open:before { content: '\e812'; } /* '' */
.tegaki-right-open:before { content: '\e813'; } /* '' */
.tegaki-eraser:before { content: '\f12d'; } /* '' */
.tegaki-pipette:before { content: '\f1fb'; } /* '' */
.tegaki-disabled,
.tegaki-disabled::after,
.tegaki-disabled::before {
opacity: 0.45;
}
.tegaki-hidden {
display: none !important;
}
.tegaki-invis {
visibility: hidden !important;
width: 0 !important;
height: 0 !important;
}
.tegaki-replay-mode #tegaki-tools-cnt,
.tegaki-replay-mode #tegaki-toolmode-bar,
.tegaki-replay-mode .tegaki-ctrlgrp,
.tegaki-replay-mode .tegaki-layers-cell,
.tegaki-replay-mode #tegaki-layers-ctrl {
pointer-events: none;
}
.tegaki-replay-mode #tegaki-ctrlgrp-zoom,
.tegaki-replay-mode #tegaki-ctrlgrp-layers {
pointer-events: auto;
}
#tegaki {
position: fixed;
width: 100%;
height: 100%;
top: 0;
left: 0;
background-color: #a3b1bf;
color: #222;
font-family: arial, sans-serif;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
z-index: 9999;
display: grid;
grid-template-columns: 40px 1fr 160px;
grid-template-rows: 24px 1fr 18px;
gap: 2px;
}
#tegaki input {
color: inherit;
}
#tegaki > div {
background-color: #8d99a6;
}
#tegaki-menu-cnt {
grid-area: 1 / 1 / 2 / 4;
white-space: nowrap;
overflow: hidden;
display: flex;
}
#tegaki-tools-cnt {
grid-area: 2 / 1 / 4 / 2;
padding: 4px;
}
#tegaki-canvas-cnt {
grid-area: 2 / 2 / 3 / 3;
overflow: auto;
display: flex;
}
#tegaki-ctrl-cnt {
grid-area: 2 / 3 / 4 / 4;
padding: 6px;
overflow: hidden auto;
}
#tegaki-status-cnt {
grid-area: 3 / 2 / 4 / 3;
line-height: 18px;
display: flex;
}
#tegaki-status-cnt > div {
padding: 0 4px;
}
#tegaki-status-replay {
color: #a61930;
}
#tegaki-status-output {
font-size: 11px;
font-weight: bold;
}
#tegaki-status-version {
color: #adbdcc;
font-size: 11px;
margin-left: auto;
}
#tegaki-menu-bar {
font-size: 12px;
padding-left: 4px;
padding-right: 18px;
border-right: 2px solid #a3b1bf;
}
.tegaki-replay-mode #tegaki-menu-bar {
padding-right: 4px;
}
.tegaki-menu-lbl {
margin: 0 2px;
vertical-align: middle;
}
#tegaki-replay-controls {
padding-right: 10px;
padding-left: 10px;
border-right: 2px solid #a3b1bf;
font-size: 11px;
}
#tegaki-replay-timeline {
display: inline-block;
width: 100px;
height: 24px;
margin: 0 4px;
border-left: 1px solid #a3b1bf;
border-right: 1px solid #a3b1bf;
/* background-color: rgba(0, 0, 0, 0.25); */
}
#tegaki-replay-timeline-fill {
display: inline-block;
width: 36px;
height: 100%;
background-color: #a3b1bf;
}
#tegaki-replay-controls > span {
vertical-align: middle;
}
#tegaki-replay-controls .tegaki-ui-cb-w {
margin-right: 4px;
}
#tegaki-replay-speed-lbl {
width: 28px;
display: inline-block;
text-align: center;
}
#tegaki-replay-speed-lbl::before {
content: '×';
}
#tegaki-replay-now-lbl,
#tegaki-replay-end-lbl {
display: inline-block;
max-width: 50px;
min-width: 30px;
overflow: hidden;
text-align: center;
margin: 0 4px;
}
#tegaki-toolmode-bar {
font-size: 11px;
margin-left: 4px;
line-height: 24px;
}
.tegaki-toolmode-lbl {
margin-right: 6px;
}
.tegaki-toolmode-lbl::after {
content: ':';
}
.tegaki-toolmode-grp {
border-left: 1px solid #a3b1bf;
padding: 0 18px;
}
#tegaki canvas {
image-rendering: optimizespeed;
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: pixelated;
-ms-interpolation-mode: nearest-neighbor;
}
.tegaki-tool-active {
color: #f2f3f4;
}
.tegaki-tool-btn {
width: 32px;
height: 32px;
display: block;
margin: auto;
}
.tegaki-tool-btn:hover {
background-color: rgba(0, 0, 0, 0.15);
}
.tegaki-tool-btn:before {
font-size: 20px;
width: 32px;
height: 32px;
line-height: 32px;
}
.tegaki-mb-btn {
cursor: default;
text-decoration: none;
display: inline-block;
padding: 0 6px;
word-spacing: -1px;
position: relative;
line-height: 24px;
height: 24px;
}
.tegaki-mb-btn:hover:not(.tegaki-disabled),
.tegaki-ui-btn:hover:not(.tegaki-disabled) {
background-color: rgba(0, 0, 0, 0.10);
}
.tegaki-sw-btn {
display: inline;
padding: 2px 6px;
margin: 0 2px;
box-shadow: 1px 1px 0 rgba(0, 0, 0, 0.15), -1px -1px 0 rgba(255, 255, 255, 0.15);
}
.tegaki-sw-btn:hover:not(.tegaki-sw-btn-a) {
background-color: rgba(0, 0, 0, 0.05);
}
.tegaki-sw-btn-a {
background-color: rgba(0, 0, 0, 0.1);
box-shadow: -1px -1px 0 rgba(0, 0, 0, 0.15), 1px 1px 0 rgba(255, 255, 255, 0.15);
}
#tegaki-toolmode-bar .tegaki-mb-btn-a {
color: inherit;
background-color: rgba(0, 0, 0, 0.10);
}
#tegaki-toolmode-bar .tegaki-mb-btn.tegaki-mb-btn-a:hover {
color: inherit;
}
#tegaki-debug {
position: absolute;
left: 0;
top: 0;
}
#tegaki-debug canvas {
width: 75px;
height: 75px;
display: block;
border: 1px solid black;
}
.tegaki-backdrop {
overflow: hidden;
}
.tegaki-hidden {
display: none !important;
}
.tegaki-strike {
text-decoration: line-through;
}
#tegaki-layers {
position: relative;
font-size: 0;
box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.25);
contain: content;
}
#tegaki-layers canvas {
width: 100%;
height: 100%;
}
#tegaki-layers:empty {
display: none;
}
#tegaki-layers-wrap {
margin: auto;
padding: 50px;
pointer-events: none;
}
#tegaki-layers canvas {
position: absolute;
left: 0;
top: 0;
}
#tegaki-finish-btn {
font-weight: bold;
}
#tegaki-cursor-layer {
position: absolute;
mix-blend-mode: difference;
touch-action: none;
}
/* generic ui */
.tegaki-alpha-bg,
.tegaki-alpha-bg-xs {
background-color: #fefefe;
background-image:
linear-gradient(45deg, #cacaca 25%, transparent 25%, transparent 75%, #cacaca 75%, #cacaca),
linear-gradient(45deg, #cacaca 25%, transparent 25%, transparent 75%, #cacaca 75%, #cacaca);
}
.tegaki-alpha-bg {
background-size: 16px 16px;
background-position: 0 0, 8px 8px;
}
.tegaki-alpha-bg-xs {
background-size: 6px 6px;
background-position: 0 0, 3px 3px;
}
.tegaki-ellipsis {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.tegaki-ui-cb {
display: inline-block;
vertical-align: middle;
width: 10px;
height: 10px;
border: 1px solid #222;
cursor: default;
margin-right: 4px;
}
.tegaki-ui-cb::after,
.tegaki-ui-cb-a::after {
display: block;
content: ' ';
width: 6px;
height: 6px;
margin-top: 2px;
margin-left: 2px;
}
.tegaki-ui-cb-a::after {
background-color: #222;
}
.tegaki-ui-cb-w:hover .tegaki-ui-cb::after,
.tegaki-ui-cb:hover::after {
background-color: #555;
}
.tegaki-ui-ellipsis::after {
content: '...';
letter-spacing: -1px;
}
.tegaki-ui-borderless {
border: none;
}
.tegaki-ui-btn {
display: inline-block;
}
.tegaki-ui-btn:before {
height: 24px;
width: 24px;
line-height: 24px;
font-size: 14px;
}
.tegaki-stealth-input {
border: 0;
margin: 0;
padding: 0;
background: none;
}
.tegaki-stealth-input:hover:not(.tegaki-disabled) {
background-color: rgba(0, 0, 0, 0.1);
}
.tegaki-range-lbl,
.tegaki-range-lbl-xs {
display: inline-block;
text-align: center;
vertical-align: top;
}
.tegaki-range-lbl {
width: 28px;
font-size: 12px;
margin-left: 4px;
}
.tegaki-range-lbl-xs {
width: 20px;
font-size: 10px;
}
.tegaki-label-xs {
font-size: 10px;
vertical-align: top;
}
.tegaki-lbl-c::after {
content: ':';
}
.tegaki-lbl-p::after {
content: '%';
margin-left: 1px;
}
.tegaki-drag-lbl:not(.tegaki-disabled) {
cursor: ew-resize;
}
.tegaki-disabled .tegaki-drag-lbl {
cursor: auto;
}
/* control groups */
.tegaki-ctrlgrp {
margin-bottom: 10px;
}
.tegaki-ctrlgrp:last-child {
margin-bottom: 0;
}
.tegaki-ctrlgrp-title {
font-size: 12px;
font-weight: bold;
margin-bottom: 6px;
background-color: #a3b1bf;
padding: 1px 4px;
}
.tegaki-ctrlrow {
font-size: 11px;
}
.tegaki-ctrlrow:not(:last-child) {
margin-bottom: 6px;
}
.tegaki-ctrl-range {
width: calc(100% - 34px);
padding: 0;
margin: 0;
height: 14px;
}
/* zoom ctrl group */
#tegaki-zoom-lbl {
display: inline-block;
font-size: 12px;
float: right;
height: 24px;
line-height: 24px;
}
/* color ctrl group */
#tegaki-color-ctrl {
display: flex;
}
#tegaki-palette-switcher {
align-self: center;
margin-left: auto;
}
.tegaki-color-grid {
display: grid;
gap: 4px;
margin-top: 6px;
}
.tegaki-color-grid-20 {
grid-template-columns: repeat(auto-fill, 20px);
grid-auto-rows: 20px;
}
.tegaki-color-grid-15 {
grid-template-columns: repeat(auto-fill, 15px);
grid-auto-rows: 15px;
}
.tegaki-color-btn {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}
#tegaki-color,
#tegaki-colorpicker {
padding: 0;
border: 0;
display: block;
width: 28px;
height: 28px;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
}
/* layers ctrl group */
#tegaki-ctrlgrp-layers {
position: relative;
}
#tegaki-layers-opts {
height: 18px;
display: flex;
}
#tegaki-layer-alpha-cell {
margin-left: auto;
}
#tegaki-layers-ctrl {
margin-top: 4px;
}
#tegaki-layers-grid {
height: 84px;
min-height: 84px;
overflow: auto;
background-color: #8d99a6;
display: flex;
flex-direction: column;
border: 1px solid #a3b1bf;
resize: vertical;
}
.tegaki-layers-cell {
box-sizing: border-box;
box-shadow: 0 1px 0 0px #a3b1bf;
padding: 0;
height: 28px;
flex-shrink: 0;
overflow: hidden;
display: flex;
align-items: center;
}
.tegaki-layers-cell-s,
.tegaki-layers-cell-a {
background-color: #a3b1bf7f;
}
.tegaki-layers-cell-a {
font-weight: bold;
}
.tegaki-layers-cell-v {
margin: 0 6px 0 4px;
}
.tegaki-layers-cell-v .tegaki-ui-cb {
vertical-align: unset;
margin: 0;
}
.tegaki-layers-cell-p {
margin-right: 6px;
}
.tegaki-layers-cell-p canvas {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
vertical-align: middle;
}
.tegaki-layers-cell-s .tegaki-layers-cell-p canvas {
box-shadow: 0 0 0 1px rgba(0, 0, 0, 1.0);
}
.tegaki-layers-cell-n {
font-size: 11px;
margin-right: 1px;
min-width: 20px;
}
.tegaki-layers-cell-d {
box-shadow: inset 0 -2px 0 0 #000;
z-index: 2;
}
#tegaki-layers-grid.tegaki-layers-cell-d {
box-shadow: 0 -2px 0 0px #000;
}
#tegaki-layers-cell-dx {
position: absolute;
background: transparent;
width: 100%;
height: 32px;
margin-top: -32px;
}
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -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');
+38 -2
View File
@@ -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;
+19 -1
View File
@@ -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
+27 -1
View File
@@ -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",
+6
View File
@@ -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;
}; };
+72 -1
View File
@@ -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
View File
@@ -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'));
+19 -2
View File
@@ -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);
});
});
+7
View File
@@ -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.';
+107
View File
@@ -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;
};
+1
View File
@@ -82,6 +82,7 @@ const DIRECTORY_CODE_ORDER = [
'wsg', 'wsg',
'diy', 'diy',
'out', 'out',
'i',
'ic', 'ic',
'mu', 'mu',
'int', 'int',
+8
View File
@@ -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');