Consolidate docs and finalize APK companion updates

This commit is contained in:
Dwindi Ramadhana
2026-03-16 01:06:41 +07:00
parent 95609dc0cf
commit 88218c7798
48 changed files with 7847 additions and 4502 deletions

View File

@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
@@ -22,22 +28,19 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="dewemoji.com" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="www.dewemoji.com" />
</intent-filter>
</activity>
<service
android:name=".OverlayBubbleService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse"
android:stopWithTask="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Floating overlay bubble for quick emoji search and copy" />
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -48,8 +51,4 @@
android:resource="@xml/file_paths"></meta-data>
</provider>
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -16,6 +16,7 @@ import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.dewemoji.app.plugins.DewemojiOverlayPlugin;
import com.getcapacitor.BridgeActivity;
import org.json.JSONObject;
@@ -30,24 +31,44 @@ import java.nio.charset.StandardCharsets;
public class MainActivity extends BridgeActivity {
private static final String TAG = "DewemojiUpdater";
private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json";
private static final String EXTRA_OPENED_FROM_BUBBLE = "dewemoji_opened_from_bubble";
private static final int CONNECT_TIMEOUT_MS = 10000;
private static final int READ_TIMEOUT_MS = 15000;
private boolean openedFromBubble = false;
@Override
protected void onCreate(Bundle savedInstanceState) {
registerPlugin(DewemojiOverlayPlugin.class);
super.onCreate(savedInstanceState);
hideSystemBars();
openedFromBubble = wasOpenedFromBubble(getIntent());
applySystemBarMode();
getWindow().getDecorView().post(this::applySystemBarMode);
checkForUpdates(false);
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
openedFromBubble = wasOpenedFromBubble(intent);
applySystemBarMode();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) {
hideSystemBars();
applySystemBarMode();
}
}
@Override
public void onResume() {
super.onResume();
applySystemBarMode();
getWindow().getDecorView().post(this::applySystemBarMode);
}
@Override
public void onBackPressed() {
WebView webView = getBridge() != null ? getBridge().getWebView() : null;
@@ -80,6 +101,20 @@ public class MainActivity extends BridgeActivity {
}
}
private boolean wasOpenedFromBubble(Intent intent) {
return intent != null && intent.getBooleanExtra(EXTRA_OPENED_FROM_BUBBLE, false);
}
private void applySystemBarMode() {
// Fallback to non-immersive mode whenever the overlay bubble service is active;
// some launch paths may not preserve the intent extra on all OEMs.
if (openedFromBubble || OverlayBubbleService.isRunning()) {
showSystemBars();
return;
}
hideSystemBars();
}
private void hideSystemBars() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
WindowInsetsControllerCompat controller =
@@ -90,6 +125,13 @@ public class MainActivity extends BridgeActivity {
);
}
private void showSystemBars() {
WindowCompat.setDecorFitsSystemWindows(getWindow(), true);
WindowInsetsControllerCompat controller =
new WindowInsetsControllerCompat(getWindow(), getWindow().getDecorView());
controller.show(WindowInsetsCompat.Type.statusBars() | WindowInsetsCompat.Type.navigationBars());
}
private void checkForUpdates(boolean manual) {
new Thread(() -> {
try {

View File

@@ -0,0 +1,715 @@
package com.dewemoji.app;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ServiceInfo;
import android.graphics.PixelFormat;
import android.graphics.drawable.GradientDrawable;
import android.os.Build;
import android.os.IBinder;
import android.provider.Settings;
import android.util.Log;
import android.view.Gravity;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.webkit.JavascriptInterface;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.Toast;
import androidx.annotation.Nullable;
import androidx.core.app.NotificationCompat;
public class OverlayBubbleService extends Service {
private static final String TAG = "DewemojiBubbleService";
public static final String ACTION_START = "com.dewemoji.app.action.BUBBLE_START";
public static final String ACTION_STOP = "com.dewemoji.app.action.BUBBLE_STOP";
public static final String ACTION_OPEN = "com.dewemoji.app.action.BUBBLE_OPEN";
public static final String PREFS_NAME = "dewemoji_native_state";
public static final String PREF_BUBBLE_X = "bubblePositionX";
public static final String PREF_BUBBLE_Y = "bubblePositionY";
public static final String NOTIFICATION_CHANNEL_ID = "dewemoji_bubble";
private static final int NOTIFICATION_ID = 41001;
private static volatile boolean running = false;
private WindowManager windowManager;
private ImageView bubbleView;
private View panelBackdropView;
private View panelView;
private WebView panelWebView;
private WindowManager.LayoutParams bubbleParams;
private WindowManager.LayoutParams panelBackdropParams;
private WindowManager.LayoutParams panelParams;
private SharedPreferences prefs;
public static boolean isRunning() {
return running;
}
@Override
public void onCreate() {
super.onCreate();
windowManager = (WindowManager) getSystemService(WINDOW_SERVICE);
prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
ensureNotificationChannel();
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
final String action = intent != null ? intent.getAction() : null;
if (ACTION_STOP.equals(action)) {
stopSelf();
return START_NOT_STICKY;
}
if (ACTION_OPEN.equals(action)) {
openMainActivity();
if (running) {
startForegroundCompat();
return START_STICKY;
}
}
if (!canDrawOverlays()) {
Toast.makeText(this, "Overlay permission is required for Dewemoji bubble", Toast.LENGTH_SHORT).show();
stopSelf();
return START_NOT_STICKY;
}
try {
startForegroundCompat();
ensureBubbleView();
// `isAttachedToWindow()` can be false immediately after addView on some
// devices/OEM builds even though the overlay is successfully added.
running = bubbleView != null;
if (!running) {
Toast.makeText(this, "Could not show Dewemoji bubble", Toast.LENGTH_SHORT).show();
stopSelf();
return START_NOT_STICKY;
}
return START_STICKY;
} catch (Exception ex) {
Log.e(TAG, "Failed to start bubble service", ex);
Toast.makeText(this, "Failed to start Dewemoji bubble", Toast.LENGTH_SHORT).show();
stopSelf();
return START_NOT_STICKY;
}
}
@Override
public void onDestroy() {
running = false;
removePanelView();
removeBubbleView();
stopForeground(STOP_FOREGROUND_REMOVE);
super.onDestroy();
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
return null;
}
private boolean canDrawOverlays() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(this);
}
private void ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return;
}
NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
if (manager == null) {
return;
}
NotificationChannel channel = manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID);
if (channel != null) {
return;
}
channel = new NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Dewemoji Bubble",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Quick access bubble for search and copy");
manager.createNotificationChannel(channel);
}
private Notification buildNotification() {
PendingIntent openPendingIntent = PendingIntent.getActivity(
this,
1001,
buildMainActivityIntent(),
pendingIntentFlags(true)
);
Intent stopIntent = new Intent(this, OverlayBubbleService.class);
stopIntent.setAction(ACTION_STOP);
PendingIntent stopPendingIntent = PendingIntent.getService(
this,
1002,
stopIntent,
pendingIntentFlags(false)
);
return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle("Dewemoji bubble is running")
.setContentText("Tap bubble to open Dewemoji, copy emoji, then paste in your other app")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(openPendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.addAction(android.R.drawable.ic_menu_view, "Open Dewemoji", openPendingIntent)
.addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop Bubble", stopPendingIntent)
.build();
}
private void startForegroundCompat() {
Notification notification = buildNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
);
return;
}
startForeground(NOTIFICATION_ID, notification);
}
private int pendingIntentFlags(boolean updateCurrent) {
int flags = updateCurrent ? PendingIntent.FLAG_UPDATE_CURRENT : 0;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
flags |= PendingIntent.FLAG_IMMUTABLE;
}
return flags;
}
private Intent buildMainActivityIntent() {
Intent launch = new Intent(this, MainActivity.class);
launch.setAction(Intent.ACTION_MAIN);
launch.addCategory(Intent.CATEGORY_LAUNCHER);
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
launch.putExtra("dewemoji_opened_from_bubble", true);
return launch;
}
private void openMainActivity() {
try {
hidePanelView();
startActivity(buildMainActivityIntent());
} catch (Exception ex) {
Toast.makeText(this, "Could not open Dewemoji", Toast.LENGTH_SHORT).show();
}
}
private void ensureBubbleView() {
if (bubbleView != null && bubbleView.isAttachedToWindow()) {
return;
}
if (windowManager == null) {
return;
}
bubbleView = new ImageView(this);
bubbleView.setImageResource(R.drawable.dewemoji_bubble_mark);
bubbleView.setScaleType(ImageView.ScaleType.CENTER_CROP);
int padding = dp(3);
bubbleView.setPadding(padding, padding, padding, padding);
GradientDrawable background = new GradientDrawable();
background.setShape(GradientDrawable.OVAL);
background.setColor(0xFFFFFFFF);
background.setStroke(dp(1), 0x22000000);
bubbleView.setBackground(background);
bubbleView.setElevation(dp(6));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
bubbleView.setClipToOutline(true);
}
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE;
bubbleParams = new WindowManager.LayoutParams(
dp(64),
dp(64),
type,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
PixelFormat.TRANSLUCENT
);
bubbleParams.gravity = Gravity.TOP | Gravity.START;
bubbleParams.x = prefs.getInt(PREF_BUBBLE_X, dp(10));
bubbleParams.y = prefs.getInt(PREF_BUBBLE_Y, dp(180));
clampBubblePosition();
bubbleView.setOnTouchListener(new View.OnTouchListener() {
private int startX;
private int startY;
private float downRawX;
private float downRawY;
private long downTime;
private boolean moved;
@Override
public boolean onTouch(View v, MotionEvent event) {
if (bubbleParams == null || windowManager == null) {
return false;
}
switch (event.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
startX = bubbleParams.x;
startY = bubbleParams.y;
downRawX = event.getRawX();
downRawY = event.getRawY();
downTime = System.currentTimeMillis();
moved = false;
return true;
case MotionEvent.ACTION_MOVE:
int dx = Math.round(event.getRawX() - downRawX);
int dy = Math.round(event.getRawY() - downRawY);
if (Math.abs(dx) > dp(3) || Math.abs(dy) > dp(3)) {
moved = true;
}
bubbleParams.x = startX + dx;
bubbleParams.y = startY + dy;
clampBubblePosition();
try {
windowManager.updateViewLayout(bubbleView, bubbleParams);
} catch (Exception ignored) {}
return true;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
persistBubblePosition();
long pressDuration = System.currentTimeMillis() - downTime;
if (!moved && pressDuration < 325) {
togglePanelView();
} else if (moved) {
positionPanelNearBubble(false);
try {
if (panelView != null && panelView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelView, panelParams);
}
} catch (Exception ignored) {}
}
return true;
default:
return false;
}
}
});
try {
windowManager.addView(bubbleView, bubbleParams);
} catch (Exception ex) {
Log.e(TAG, "windowManager.addView failed", ex);
String reason = ex.getClass().getSimpleName();
String message = ex.getMessage();
if (message != null && !message.trim().isEmpty()) {
String oneLine = message.replace('\n', ' ').trim();
if (oneLine.length() > 90) oneLine = oneLine.substring(0, 90) + "";
reason = reason + ": " + oneLine;
}
Toast.makeText(this, reason, Toast.LENGTH_LONG).show();
bubbleView = null;
}
}
private void persistBubblePosition() {
if (bubbleParams == null || prefs == null) {
return;
}
prefs.edit()
.putInt(PREF_BUBBLE_X, bubbleParams.x)
.putInt(PREF_BUBBLE_Y, bubbleParams.y)
.apply();
}
private void togglePanelView() {
if (panelView != null && panelView.isAttachedToWindow()) {
hidePanelView();
return;
}
showPanelView();
}
private void showPanelView() {
if (windowManager == null) return;
ensurePanelBackdropView();
ensurePanelView();
if (panelView == null || panelParams == null) return;
showPanelBackdrop();
positionPanelNearBubble(true);
try {
if (panelView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelView, panelParams);
} else {
windowManager.addView(panelView, panelParams);
}
} catch (Exception ex) {
Log.e(TAG, "windowManager.addView(panel) failed", ex);
Toast.makeText(this, "Failed to show panel", Toast.LENGTH_SHORT).show();
}
}
private void hidePanelView() {
if (windowManager != null && panelView != null) {
try {
if (panelView.isAttachedToWindow()) {
windowManager.removeView(panelView);
}
} catch (Exception ignored) {
}
}
hidePanelBackdrop();
}
private void removePanelView() {
hidePanelView();
if (panelWebView != null) {
try {
panelWebView.stopLoading();
panelWebView.loadUrl("about:blank");
panelWebView.destroy();
} catch (Exception ignored) {
}
}
panelWebView = null;
panelView = null;
panelParams = null;
panelBackdropView = null;
panelBackdropParams = null;
}
private void ensurePanelBackdropView() {
if (panelBackdropView != null && panelBackdropParams != null) return;
if (windowManager == null) return;
View backdrop = new View(this);
backdrop.setBackgroundColor(0x26000000);
backdrop.setClickable(true);
backdrop.setFocusable(false);
backdrop.setOnTouchListener((v, event) -> {
if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
hidePanelView();
}
return true;
});
panelBackdropView = backdrop;
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE;
panelBackdropParams = new WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
type,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT
);
panelBackdropParams.gravity = Gravity.TOP | Gravity.START;
panelBackdropParams.x = 0;
panelBackdropParams.y = 0;
}
private void showPanelBackdrop() {
if (windowManager == null || panelBackdropView == null || panelBackdropParams == null) return;
try {
if (panelBackdropView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelBackdropView, panelBackdropParams);
} else {
windowManager.addView(panelBackdropView, panelBackdropParams);
}
} catch (Exception ex) {
Log.w(TAG, "Failed to show panel backdrop", ex);
}
}
private void hidePanelBackdrop() {
if (windowManager == null || panelBackdropView == null) return;
try {
if (panelBackdropView.isAttachedToWindow()) {
windowManager.removeView(panelBackdropView);
}
} catch (Exception ignored) {
}
}
private void ensurePanelView() {
if (panelView != null && panelParams != null) return;
if (windowManager == null) return;
FrameLayout card = new FrameLayout(this);
card.setClickable(true);
card.setFocusable(true);
card.setFocusableInTouchMode(true);
card.setElevation(dp(18));
GradientDrawable bg = new GradientDrawable();
bg.setColor(0xFFFFFFFF);
bg.setCornerRadius(dp(18));
bg.setStroke(dp(1), 0x22000000);
card.setBackground(bg);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
card.setClipToOutline(true);
}
WebView webView = new WebView(this);
webView.setOverScrollMode(View.OVER_SCROLL_NEVER);
webView.setVerticalScrollBarEnabled(false);
webView.setHorizontalScrollBarEnabled(false);
webView.setBackgroundColor(0xFFFFFFFF);
webView.setWebChromeClient(new WebChromeClient());
webView.setWebViewClient(new WebViewClient());
webView.addJavascriptInterface(new OverlayWebBridge(), "DewemojiOverlayHost");
WebSettings ws = webView.getSettings();
ws.setJavaScriptEnabled(true);
ws.setDomStorageEnabled(true);
ws.setAllowFileAccess(true);
ws.setAllowContentAccess(true);
ws.setMediaPlaybackRequiresUserGesture(false);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
ws.setAllowFileAccessFromFileURLs(true);
ws.setAllowUniversalAccessFromFileURLs(true);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
ws.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
}
FrameLayout.LayoutParams webLp = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
);
card.addView(webView, webLp);
panelWebView = webView;
try {
webView.loadUrl(buildOverlayWebViewUrl());
} catch (Exception ex) {
Log.w(TAG, "Failed to load overlay webview URL", ex);
}
panelView = card;
int type = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
: WindowManager.LayoutParams.TYPE_PHONE;
panelParams = new WindowManager.LayoutParams(
preferredPanelWidthPx(),
maxPanelHeightPx(),
type,
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL,
PixelFormat.TRANSLUCENT
);
panelParams.gravity = Gravity.TOP | Gravity.START;
panelParams.x = dp(12);
panelParams.y = dp(120);
panelParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
}
private void positionPanelNearBubble(boolean resetIfOffscreen) {
if (panelParams == null) return;
int screenW = getResources().getDisplayMetrics().widthPixels;
int screenH = getResources().getDisplayMetrics().heightPixels;
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
panelParams.width = preferredPanelWidthPx();
panelParams.height = Math.min(panelParams.height > 0 ? panelParams.height : maxPanelHeightPx(), maxPanelHeightPx());
panelParams.x = Math.max(safeSideMarginPx(), (screenW - panelParams.width) / 2);
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
clampPanelPosition();
}
private void clampPanelPosition() {
if (panelParams == null) return;
int screenW = getResources().getDisplayMetrics().widthPixels;
int screenH = getResources().getDisplayMetrics().heightPixels;
int sideMargin = safeSideMarginPx();
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int panelW = panelParams.width > 0 ? panelParams.width : preferredPanelWidthPx();
int panelH = panelParams.height > 0 ? panelParams.height : maxPanelHeightPx();
int maxX = Math.max(sideMargin, screenW - panelW - sideMargin);
int maxY = Math.max(topSafe, screenH - panelH - bottomSafe);
panelParams.x = Math.max(sideMargin, Math.min(panelParams.x, maxX));
panelParams.y = Math.max(topSafe, Math.min(panelParams.y, maxY));
}
private void clampBubblePosition() {
if (bubbleParams == null) return;
int screenW = getResources().getDisplayMetrics().widthPixels;
int screenH = getResources().getDisplayMetrics().heightPixels;
int sideMargin = safeSideMarginPx();
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int bubbleW = bubbleParams.width > 0 ? bubbleParams.width : dp(64);
int bubbleH = bubbleParams.height > 0 ? bubbleParams.height : dp(64);
int maxX = Math.max(sideMargin, screenW - bubbleW - sideMargin);
int maxY = Math.max(topSafe, screenH - bubbleH - bottomSafe);
bubbleParams.x = Math.max(sideMargin, Math.min(bubbleParams.x, maxX));
bubbleParams.y = Math.max(topSafe, Math.min(bubbleParams.y, maxY));
}
private int preferredPanelWidthPx() {
int screenW = getResources().getDisplayMetrics().widthPixels;
int minMargin = dp(10);
int maxWidth = Math.max(dp(220), screenW - (minMargin * 2));
int target = Math.round(screenW * 0.94f);
int minWidth = Math.min(dp(280), maxWidth);
return Math.max(minWidth, Math.min(maxWidth, target));
}
private int maxPanelHeightPx() {
int screenH = getResources().getDisplayMetrics().heightPixels;
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int available = Math.max(dp(360), screenH - topSafe - bottomSafe);
int maxHeight = Math.max(dp(260), available - dp(8));
int target = Math.round(available * 0.92f);
int minHeight = Math.min(dp(360), maxHeight);
return Math.max(minHeight, Math.min(maxHeight, target));
}
private int minPanelHeightPx() {
int max = maxPanelHeightPx();
return Math.min(max, dp(260));
}
private int safeSideMarginPx() {
return dp(10);
}
private int safeTopInsetPx() {
return readSystemDimenPx("status_bar_height") + dp(8);
}
private int safeBottomInsetPx() {
return readSystemDimenPx("navigation_bar_height") + dp(8);
}
private int readSystemDimenPx(String name) {
try {
int resId = getResources().getIdentifier(name, "dimen", "android");
if (resId > 0) {
return getResources().getDimensionPixelSize(resId);
}
} catch (Exception ignored) {
}
return 0;
}
private String buildOverlayWebViewUrl() {
return "file:///android_asset/public/index.html?mode=overlay";
}
private boolean copyTextToClipboardNative(String text) {
try {
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboard == null) return false;
clipboard.setPrimaryClip(ClipData.newPlainText("dewemoji", String.valueOf(text == null ? "" : text)));
return true;
} catch (Exception ex) {
Log.w(TAG, "Native clipboard copy failed", ex);
return false;
}
}
private final class OverlayWebBridge {
@JavascriptInterface
public boolean copyText(String text) {
return copyTextToClipboardNative(text);
}
@JavascriptInterface
public void setContentHeight(int cssPx) {
if (cssPx <= 0) return;
View target = panelView;
if (target == null) return;
target.post(() -> applyPanelContentHeightCss(cssPx));
}
@JavascriptInterface
public void closePanel() {
hidePanelView();
}
@JavascriptInterface
public void openFullApp() {
openMainActivity();
}
}
private void applyPanelContentHeightCss(int cssPx) {
if (panelParams == null || panelView == null || windowManager == null) return;
float density = getResources().getDisplayMetrics().density;
int desiredPx = Math.round(cssPx * density);
desiredPx += dp(2);
desiredPx = Math.max(minPanelHeightPx(), Math.min(desiredPx, maxPanelHeightPx()));
if (Math.abs(desiredPx - panelParams.height) < dp(6)) {
return;
}
panelParams.height = desiredPx;
int screenH = getResources().getDisplayMetrics().heightPixels;
int topSafe = safeTopInsetPx();
int bottomSafe = safeBottomInsetPx();
int availableH = Math.max(dp(280), screenH - topSafe - bottomSafe);
panelParams.y = topSafe + Math.max(0, (availableH - panelParams.height) / 2);
clampPanelPosition();
try {
if (panelView.isAttachedToWindow()) {
windowManager.updateViewLayout(panelView, panelParams);
}
} catch (Exception ex) {
Log.w(TAG, "Failed to apply panel content height", ex);
}
}
private void removeBubbleView() {
if (windowManager == null || bubbleView == null) {
bubbleView = null;
return;
}
try {
windowManager.removeView(bubbleView);
} catch (Exception ignored) {
} finally {
bubbleView = null;
}
}
private int dp(int value) {
float density = getResources().getDisplayMetrics().density;
return Math.round(value * density);
}
}

View File

@@ -0,0 +1,173 @@
package com.dewemoji.app.plugins;
import android.Manifest;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import com.dewemoji.app.OverlayBubbleService;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
@CapacitorPlugin(name = "DewemojiOverlay")
public class DewemojiOverlayPlugin extends Plugin {
private static final String APP_STATE_KEY = "app_state_json";
@PluginMethod
public void isOverlayPermissionGranted(PluginCall call) {
JSObject out = new JSObject();
out.put("granted", canDrawOverlays());
call.resolve(out);
}
@PluginMethod
public void openOverlayPermissionSettings(PluginCall call) {
try {
Intent intent = new Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:" + getContext().getPackageName())
);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
call.resolve();
} catch (Exception ex) {
call.reject("Failed to open overlay settings", ex);
}
}
@PluginMethod
public void areNotificationsEnabled(PluginCall call) {
JSObject out = new JSObject();
out.put("enabled", notificationsEnabled());
call.resolve(out);
}
@PluginMethod
public void openNotificationSettings(PluginCall call) {
try {
Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS);
intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName());
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
getContext().startActivity(intent);
call.resolve();
} catch (Exception ex) {
call.reject("Failed to open notification settings", ex);
}
}
@PluginMethod
public void isBubbleRunning(PluginCall call) {
JSObject out = new JSObject();
out.put("running", OverlayBubbleService.isRunning());
call.resolve(out);
}
@PluginMethod
public void startBubble(PluginCall call) {
JSObject out = new JSObject();
if (!canDrawOverlays()) {
out.put("started", false);
out.put("reason", "overlay_permission_required");
call.resolve(out);
return;
}
if (!notificationsEnabled()) {
out.put("started", false);
out.put("reason", "notifications_required");
call.resolve(out);
return;
}
try {
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
intent.setAction(OverlayBubbleService.ACTION_START);
ContextCompat.startForegroundService(getContext(), intent);
out.put("started", true);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to start Dewemoji bubble", ex);
}
}
@PluginMethod
public void stopBubble(PluginCall call) {
try {
Intent intent = new Intent(getContext(), OverlayBubbleService.class);
intent.setAction(OverlayBubbleService.ACTION_STOP);
getContext().startService(intent);
JSObject out = new JSObject();
out.put("stopped", true);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to stop Dewemoji bubble", ex);
}
}
@PluginMethod
public void getAppState(PluginCall call) {
JSObject out = new JSObject();
try {
SharedPreferences prefs = nativePrefs();
String raw = prefs.getString(APP_STATE_KEY, "{}");
JSObject state = (raw == null || raw.trim().isEmpty()) ? new JSObject() : new JSObject(raw);
out.put("state", state);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to read app state", ex);
}
}
@PluginMethod
public void setAppState(PluginCall call) {
try {
JSObject incoming = call.getObject("state", new JSObject());
if (incoming == null) {
incoming = new JSObject();
}
nativePrefs().edit().putString(APP_STATE_KEY, incoming.toString()).apply();
JSObject out = new JSObject();
out.put("saved", true);
call.resolve(out);
} catch (Exception ex) {
call.reject("Failed to save app state", ex);
}
}
private SharedPreferences nativePrefs() {
return getContext().getSharedPreferences(OverlayBubbleService.PREFS_NAME, Context.MODE_PRIVATE);
}
private boolean canDrawOverlays() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
return true;
}
return Settings.canDrawOverlays(getContext());
}
private boolean notificationsEnabled() {
if (!NotificationManagerCompat.from(getContext()).areNotificationsEnabled()) {
return false;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
return ContextCompat.checkSelfPermission(
getContext(),
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED;
}
return true;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -1,9 +1,5 @@
{
"appId": "com.dewemoji.app",
"appName": "Dewemoji",
"webDir": "www",
"server": {
"url": "https://dewemoji.com",
"cleartext": false
}
"webDir": "www"
}

View File

@@ -3,7 +3,8 @@
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "node ./scripts/build-web.js",
"test": "echo \"No tests configured\""
},
"keywords": [],
"author": "",

View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const srcDir = path.join(rootDir, 'src');
const outDir = path.join(rootDir, 'www');
function fail(message) {
console.error(`error: ${message}`);
process.exit(1);
}
if (!fs.existsSync(srcDir)) {
fail(`missing source directory: ${srcDir}`);
}
try {
fs.rmSync(outDir, { recursive: true, force: true });
fs.mkdirSync(outDir, { recursive: true });
fs.cpSync(srcDir, outDir, { recursive: true });
console.log(`Built web assets: ${path.relative(rootDir, outDir)}`);
} catch (error) {
fail(error && error.message ? error.message : String(error));
}

View File

@@ -0,0 +1,967 @@
:root {
color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
}
@media (prefers-color-scheme: dark){
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
}
/* explicit override by class */
.theme-light {
color-scheme: light;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
}
.theme-dark {
color-scheme: dark;
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
}
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
.bar { display:flex; gap:6px; }
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
/* buttons */
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
.btn:hover { filter: brightness(0.98); }
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
/* disabled buttons (e.g., Load more) */
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
.card {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
cursor: pointer;
}
.card .emo { font-size:28px; line-height:1; }
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
position:sticky; bottom:0; background:var(--bg); }
.muted { color:var(--mut); }
.nowrap { white-space: nowrap; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
}
.toast.show { opacity: 1; }
.sel {
padding: 8px 10px;
border: 1px solid var(--br);
border-radius: 8px;
background: var(--bg);
color: var(--fg);
appearance: none; /* cleaner look */
background-image:
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.sel:disabled { opacity: .6; }
.badge {
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br);
}
/* Backdrop + Sheet (modal) */
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
z-index: 80; opacity: 0; transition: opacity .18s ease;
}
.backdrop.show { opacity: 1; }
.sheet {
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
}
.sheet.show { transform: translateY(0); opacity: 1; }
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
.sheet-body { padding:12px; display:grid; gap:14px; }
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
.field .hint { margin-top:6px; font-size:12px; }
.row { display:flex; gap:8px; }
/* Radios */
.radios { display:grid; gap:8px; }
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
.radio input[type="radio"] { accent-color: #3b82f6; }
.radio[aria-disabled="true"] { opacity:.55; }
.tag {
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
border:1px solid var(--br); background: var(--dim); color: var(--fg);
}
/* Badge (version) already exists — keep it; this is just a reminder */
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
/* Buttons & inputs already set; ensure icon buttons look good */
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* apply to the glyph container you use */
.emo, .emo * {
font-family:
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
system-ui, sans-serif !important;
font-variant-emoji: emoji;
line-height: 1;
}
.emo img {
width: 1em;
height: 1em;
display: block;
}
#dewemoji-status {
float: right;
}
/* Settings tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--br);
margin-bottom: 12px;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
cursor: pointer;
background: var(--dim);
border: 1px solid var(--br);
border-bottom: none;
border-radius: 8px 8px 0 0;
font-weight: 600;
}
.tab:not(.active) {
opacity: 0.6;
}
.tab.active {
background: var(--bg);
color: var(--fg);
opacity: 1;
}
.tabpane { display: none; }
.tabpane.active { display: block; }
/* Tone palette theming */
.theme-light, :root.theme-light {
--c-bg: var(--bg, #ffffff);
--c-chip: var(--dim, #f3f4f6);
--c-border: var(--br, #e5e7eb);
}
.theme-dark, :root.theme-dark {
--c-bg: var(--bg, #0f172a);
--c-chip: var(--dim, #111827);
--c-border: var(--br, #374151);
}
/* Accent color for focus/selection rings */
.theme-light, :root.theme-light { --accent: #3b82f6; }
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
/* Tone palette buttons (popover + settings) */
.tone-btn, .tone-chip {
border: 1px solid var(--c-border);
background: var(--c-chip);
}
.tone-btn.selected, .tone-chip.selected {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent) !important;
}
.tone-btn:focus-visible, .tone-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ===== Settings sheet polish ===== */
.sheet { max-width: 540px; }
.sheet-head {
position: sticky; top: 0; background: var(--bg);
z-index: 2; border-bottom: 1px solid var(--br);
}
.sheet-head h3 { margin: 0; }
.sheet-body { padding-top: 10px; }
.field { margin: 12px 0 16px; }
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
/* Compact rows */
.row {
display: grid;
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
gap: 8px;
align-items: center;
}
.row + .row { margin-top: 6px; }
/* Inputs and small buttons */
.inp { padding: 6px 10px; }
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
.btn.ghost { background: transparent; border: 1px solid var(--br); }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
.link:hover { text-decoration: underline; }
/* Tabs = segmented control */
.tabs {
background: var(--dim); padding: 4px; border-radius: 10px;
display: inline-flex; gap: 4px; border: 1px solid var(--br);
margin-bottom: 12px;
}
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
/* License subtext wraps neatly */
#license-status { display: inline-block; margin-left: 8px; }
/* Tone palette spacin.g + buttons */
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
.tone-chip {
min-width: 40px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
}
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* Toast a tad higher so it doesnt overlap sheet */
.toast { bottom: 18px; }
/* Make the top controls a proper stacking context above the grid */
.topbar, .filters-row {
position: sticky; /* or relative if not sticky */
z-index: 5;
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
}
/* Or ensure the select dropdown sits above adjacent icons */
select#sub {
position: relative;
z-index: 6;
}
div.card.active {
background-color: var(--active)!important;
}
.tone-row {
grid-column: 1 / -1;
background-color: var(--active);
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
border-radius: 12px;
padding: 12px;
display: grid;
grid-template-columns: repeat(5, minmax(0px, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
height: 56px;
border-radius: 10px;
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
background: var(--c-bg, #f3f4f6);
display: flex;
align-items: center;
justify-content: center;
font-size: x-large;
}
/* ===== APK companion overrides (extension-like, denser) ===== */
html {
min-height: 100%;
}
body {
height: auto;
min-height: 100%;
padding-top: max(0px, env(safe-area-inset-top));
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.hdr {
padding-top: calc(10px + env(safe-area-inset-top) * 0.25);
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
gap: 6px;
align-content: start;
min-height: calc(100vh - 170px);
background: var(--bg);
}
body.overlay-mode .grid {
min-height: 0;
}
html.overlay-mode,
body.overlay-mode {
min-height: 0;
height: auto;
}
body.overlay-mode {
padding-top: 0;
padding-bottom: 0;
}
body.overlay-mode .hdr,
body.overlay-mode .ft {
position: static;
}
body.overlay-mode #theme {
display: none !important;
}
body.overlay-mode #settings-sheet,
body.overlay-mode #sheet-backdrop {
display: none !important;
}
body.overlay-mode {
font-size: 15px;
}
body.overlay-mode .bar {
gap: 8px;
}
body.overlay-mode .filters {
gap: 8px;
padding: 10px 12px;
}
body.overlay-mode .inp,
body.overlay-mode .sel {
min-height: 48px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body.overlay-mode .btn {
min-height: 46px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body.overlay-mode .btn.icon {
width: 46px;
height: 46px;
min-height: 46px;
padding: 0;
font-size: 18px;
}
body.overlay-mode .btn.w {
min-width: 124px;
}
body.overlay-mode .ft {
padding: 12px;
gap: 10px;
}
body.overlay-mode .sheet {
left: 8px;
right: 8px;
bottom: 8px;
max-width: none;
border-radius: 16px;
box-shadow: 0 16px 44px rgba(0,0,0,.28);
}
body.overlay-mode .sheet-head {
padding: 14px;
}
body.overlay-mode .sheet-head h3 {
font-size: 20px;
}
body.overlay-mode .sheet-body {
padding: 14px;
gap: 16px;
}
body.overlay-mode .field {
margin: 14px 0 18px;
}
body.overlay-mode .field .lbl {
margin-bottom: 8px;
}
body.overlay-mode .field .hint,
body.overlay-mode .diagbox,
body.overlay-mode .muted {
font-size: 13px;
line-height: 1.35;
}
body.overlay-mode .diagbox {
padding: 12px;
border-radius: 10px;
}
body.overlay-mode .row,
body.overlay-mode .row-inline,
body.overlay-mode .row-wrap {
gap: 10px;
}
body.overlay-mode .row-inline {
align-items: stretch;
}
body.overlay-mode .row-wrap .btn,
body.overlay-mode .row-inline .btn {
min-height: 46px;
}
body.overlay-mode #account-connect-form .row-block .inp,
body.overlay-mode #account-connected .row-inline .btn {
min-height: 50px;
}
body.overlay-mode .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body.overlay-mode .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body.overlay-mode .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .tone-pref-group {
gap: 10px;
}
body.overlay-mode .tone-swatch {
width: 42px;
height: 42px;
}
body.overlay-mode .tone-circle {
width: 28px;
height: 28px;
}
body.overlay-mode .bubble-grid {
gap: 10px;
}
body.overlay-mode .bubble-kv {
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .bubble-kv > span:last-child {
font-size: 14px;
}
body.overlay-mode .tone-row {
gap: 8px;
padding: 8px;
}
body.overlay-mode .tone-option {
min-height: 48px;
font-size: 24px;
}
@media (pointer: coarse), (max-width: 900px) {
body:not(.overlay-mode) .inp,
body:not(.overlay-mode) .sel {
min-height: 46px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn {
min-height: 44px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn.icon {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
font-size: 18px;
}
body:not(.overlay-mode) .filters {
gap: 8px;
padding: 10px 10px;
}
body:not(.overlay-mode) .bar {
gap: 8px;
}
body:not(.overlay-mode) .ft {
gap: 10px;
padding: 12px 10px;
}
body:not(.overlay-mode) .sheet {
border-radius: 14px;
}
body:not(.overlay-mode) .sheet-head {
padding: 14px;
}
body:not(.overlay-mode) .sheet-head h3 {
font-size: 20px;
}
body:not(.overlay-mode) .sheet-body {
padding: 14px;
gap: 16px;
}
body:not(.overlay-mode) .field {
margin: 14px 0 18px;
}
body:not(.overlay-mode) .row,
body:not(.overlay-mode) .row-inline,
body:not(.overlay-mode) .row-wrap {
gap: 10px;
}
body:not(.overlay-mode) .row-inline {
align-items: stretch;
}
body:not(.overlay-mode) .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body:not(.overlay-mode) .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body:not(.overlay-mode) .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body:not(.overlay-mode) #account-connect-form .row-block .inp,
body:not(.overlay-mode) #account-connected .row-inline .btn {
min-height: 50px;
}
body:not(.overlay-mode) .bubble-kv {
padding: 10px;
border-radius: 10px;
}
}
.card {
min-height: 72px;
padding: 8px 6px;
gap: 4px;
border-radius: 10px;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card * {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card .emo {
font-size: 24px;
}
.card .nm {
font-size: 10px;
line-height: 1.15;
max-width: 100%;
}
body.emoji-names-hidden .card {
min-height: 58px;
gap: 2px;
padding-top: 7px;
padding-bottom: 7px;
}
body.emoji-names-hidden .card .nm {
display: none;
}
body.emoji-names-hidden .card .emo {
font-size: 28px;
}
.btn.w {
min-width: 110px;
}
.ft {
padding-bottom: calc(10px + env(safe-area-inset-bottom) * 0.5);
background: var(--bg);
}
.sheet {
bottom: calc(12px + env(safe-area-inset-bottom));
}
.row-inline {
display: flex;
grid-template-columns: none;
align-items: center;
}
.row-wrap {
display: flex;
grid-template-columns: none;
flex-wrap: wrap;
}
.row-block {
display: block;
grid-template-columns: none;
}
.row-block .inp {
width: 100%;
}
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.tone-pref-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tone-swatch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
cursor: pointer;
}
.tone-swatch input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.tone-circle {
display: block;
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--br);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
}
.tone-default {
background:
radial-gradient(circle at 50% 50%, transparent 0 28%, rgba(255,255,255,.7) 29% 34%, transparent 35%),
linear-gradient(180deg, #d7e0eb 0%, #a4b2c3 100%);
}
.tone-1 { background: #f7d7c4; }
.tone-2 { background: #e8bf95; }
.tone-3 { background: #c68642; }
.tone-4 { background: #8d5524; }
.tone-5 { background: #5e3b22; }
.tone-swatch input:checked + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
border-color: #60a5fa;
}
.tone-swatch input:focus-visible + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.bubble-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0 10px;
}
.bubble-kv {
display: grid;
gap: 4px;
border: 1px solid var(--br);
background: var(--bg);
border-radius: 8px;
padding: 8px;
min-width: 0;
}
.bubble-kv > span:last-child {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bubble-kv .ok { color: #059669; }
.bubble-kv .warn { color: #d97706; }
.bubble-kv .bad { color: #dc2626; }
.alert {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 10px;
}
.alert strong {
display: block;
margin-bottom: 2px;
}
.alert.error {
border-color: rgba(220, 38, 38, .35);
}
.alert.warn {
border-color: rgba(217, 119, 6, .35);
}
.autoload-sentinel {
width: 100%;
height: 1px;
}
.card.private {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, .35);
}
.card.private .nm::after {
content: " · private";
color: #3b82f6;
}
.card.toneable::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #60a5fa;
opacity: 0.9;
}
.tone-row {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 8px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
min-height: 42px;
border-radius: 10px;
border: 1px solid var(--br);
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.tone-option.selected {
box-shadow: inset 0 0 0 2px #60a5fa;
border-color: #60a5fa;
}
.tone-option.label {
font-size: 12px;
font-weight: 600;
}
.tone-note {
grid-column: 1 / -1;
font-size: 12px;
color: var(--mut);
padding: 2px 2px 0;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 5px;
padding: 8px;
}
.card {
min-height: 64px;
padding: 6px 4px;
}
.card .emo {
font-size: 22px;
}
.card .nm {
font-size: 9px;
}
body.emoji-names-hidden .card {
min-height: 52px;
padding-top: 6px;
padding-bottom: 6px;
}
body.emoji-names-hidden .card .emo {
font-size: 25px;
}
.bubble-grid {
grid-template-columns: 1fr;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tab {
width: 100%;
text-align: center;
}
}
@media (max-width: 420px) {
.card {
min-height: 54px;
gap: 2px;
}
.card .emo {
font-size: 20px;
}
.card .nm {
font-size: 8px;
}
body.emoji-names-hidden .card .emo {
font-size: 24px;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
padding: 6px;
}
.tone-option {
min-height: 38px;
font-size: 20px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,182 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Dewemoji</title>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1220" />
<link rel="preconnect" href="https://twemoji.maxcdn.com" crossorigin />
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
<link href="app.css" rel="stylesheet" />
</head>
<body class="theme-dark">
<header class="hdr">
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Guest</span></h1>
<div class="bar">
<input id="q" class="inp" type="search" placeholder="Search (e.g. love)" aria-label="Search emojis" />
<button id="clear" class="btn icon" title="Clear" aria-label="Clear"></button>
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
</div>
</header>
<section class="filters">
<select id="cat" class="sel" aria-label="Category filter">
<option value="">All categories</option>
</select>
<select id="sub" class="sel" aria-label="Subcategory filter" disabled>
<option value="">All subcategories</option>
</select>
</section>
<main id="list" class="grid" aria-live="polite"></main>
<div id="autoload-sentinel" class="autoload-sentinel" aria-hidden="true"></div>
<footer class="ft">
<button id="more" class="btn w">Load more</button>
<span id="count" class="muted nowrap">0 items</span>
<span id="ver" class="badge nowrap">APK</span>
</footer>
<div id="sheet-backdrop" class="backdrop" hidden></div>
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="sheet-head">
<h3 id="settings-title">Settings</h3>
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close"></button>
</div>
<div class="sheet-body">
<div class="tabs" role="tablist" aria-label="Settings tabs">
<button class="tab active" data-tab="general" role="tab" aria-selected="true">General</button>
<button class="tab" data-tab="account" role="tab" aria-selected="false">Account</button>
<button class="tab" data-tab="bubble" role="tab" aria-selected="false">Bubble</button>
</div>
<section id="tab-general" class="tabpane active" role="tabpanel">
<div class="field">
<label class="lbl">Instant Access</label>
<p class="hint muted">Tap an emoji to copy. Then switch back and paste in the app you are typing in.</p>
</div>
<div class="field">
<label class="lbl">Current Session</label>
<div class="diagbox" id="session-summary">Guest mode. Public keywords only.</div>
</div>
<div class="field">
<label class="lbl">Grid display</label>
<label class="radio row-inline" for="show-emoji-names-toggle">
<input id="show-emoji-names-toggle" type="checkbox" />
<span>Show emoji names under each emoji</span>
</label>
<p class="hint muted">Off by default for larger emoji and faster scanning.</p>
</div>
<div class="field">
<label class="lbl">Skin tone</label>
<label class="radio row-inline" for="tone-lock-toggle">
<input id="tone-lock-toggle" type="checkbox" />
<span>Use preferred skin tone on tap (tone lock)</span>
</label>
<div class="row row-block" style="margin-top:8px;">
<div id="preferred-skin-tone-radios" class="tone-pref-group" role="radiogroup" aria-label="Preferred skin tone">
<label class="tone-swatch" title="Default (no tone)">
<input type="radio" name="preferredSkinTone" value="0" aria-label="Default (no tone)" />
<span class="tone-circle tone-default" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Light skin tone">
<input type="radio" name="preferredSkinTone" value="1" aria-label="Light skin tone" />
<span class="tone-circle tone-1" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-light skin tone">
<input type="radio" name="preferredSkinTone" value="2" aria-label="Medium-light skin tone" />
<span class="tone-circle tone-2" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium skin tone">
<input type="radio" name="preferredSkinTone" value="3" aria-label="Medium skin tone" />
<span class="tone-circle tone-3" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-dark skin tone">
<input type="radio" name="preferredSkinTone" value="4" aria-label="Medium-dark skin tone" />
<span class="tone-circle tone-4" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Dark skin tone">
<input type="radio" name="preferredSkinTone" value="5" aria-label="Dark skin tone" />
<span class="tone-circle tone-5" aria-hidden="true"></span>
</label>
</div>
</div>
<p class="hint muted">Long press an emoji with skin tones to choose a tone quickly.</p>
</div>
<div class="field">
<label class="lbl">Refresh catalog</label>
<div class="row row-inline">
<button id="refresh" class="btn">Refresh</button>
<span id="api-status" class="muted">Loading…</span>
</div>
</div>
</section>
<section id="tab-account" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Account</label>
<div id="account-connect-form">
<div class="row row-block">
<input id="account-email" class="inp" placeholder="Email" type="email" autocomplete="username" />
</div>
<div class="row row-block" style="margin-top:6px;">
<input id="account-password" class="inp" placeholder="Password" type="password" autocomplete="current-password" />
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-login" class="btn">Connect</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span id="account-status" class="muted">Not connected. Public keywords only.</span>
</div>
</div>
<div id="account-connected" style="display:none;">
<div class="row row-inline">
<span id="account-greeting" class="muted">Connected.</span>
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-logout" class="btn ghost">Logout</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span class="muted">Private keyword matches appear in search when available.</span>
</div>
</div>
</div>
</section>
<section id="tab-bubble" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Floating bubble (quick search + copy)</label>
<p class="hint muted">Use Dewemoji bubble to quickly search and copy emoji while using other apps. Paste manually in the app youre typing in.</p>
</div>
<div class="bubble-grid">
<div class="bubble-kv"><span class="muted">Platform</span><span id="bubble-platform-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Overlay permission</span><span id="bubble-overlay-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Notifications</span><span id="bubble-notify-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Bubble service</span><span id="bubble-running-status">Checking…</span></div>
</div>
<div class="field">
<div class="row row-wrap">
<button id="bubble-enable-btn" class="btn">Enable bubble</button>
<button id="bubble-disable-btn" class="btn ghost">Disable bubble</button>
</div>
<div class="row row-wrap" style="margin-top:8px;">
<button id="bubble-overlay-settings-btn" class="btn ghost">Grant overlay permission</button>
<button id="bubble-notify-settings-btn" class="btn ghost">Open notification settings</button>
</div>
<div class="diagbox" id="bubble-help" style="margin-top:8px;">Bubble is off by default. It opens Dewemoji for quick search and copy.</div>
</div>
</section>
</div>
</aside>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="twemoji-lite.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
(() => {
const root = window;
if (root?.twemoji?.convert?.toCodePoint) return;
function toCodePoint(unicodeSurrogates, sep = '-') {
const r = [];
let c = 0;
let p = 0;
let i = 0;
const s = String(unicodeSurrogates || '');
while (i < s.length) {
c = s.charCodeAt(i++);
if (p) {
r.push((0x10000 + ((p - 0xD800) * 0x400) + (c - 0xDC00)).toString(16));
p = 0;
} else if (c >= 0xD800 && c <= 0xDBFF) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join(sep);
}
root.twemoji = root.twemoji || {};
root.twemoji.convert = root.twemoji.convert || {};
root.twemoji.convert.toCodePoint = toCodePoint;
})();

View File

@@ -0,0 +1,967 @@
:root {
color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
}
@media (prefers-color-scheme: dark){
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
}
/* explicit override by class */
.theme-light {
color-scheme: light;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
}
.theme-dark {
color-scheme: dark;
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
}
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); z-index: 9;}
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
.bar { display:flex; gap:6px; }
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
/* buttons */
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
.btn:hover { filter: brightness(0.98); }
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
/* disabled buttons (e.g., Load more) */
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
.card {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
cursor: pointer;
}
.card .emo { font-size:28px; line-height:1; }
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
position:sticky; bottom:0; background:var(--bg); }
.muted { color:var(--mut); }
.nowrap { white-space: nowrap; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
}
.toast.show { opacity: 1; }
.sel {
padding: 8px 10px;
border: 1px solid var(--br);
border-radius: 8px;
background: var(--bg);
color: var(--fg);
appearance: none; /* cleaner look */
background-image:
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.sel:disabled { opacity: .6; }
.badge {
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br);
}
/* Backdrop + Sheet (modal) */
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
z-index: 80; opacity: 0; transition: opacity .18s ease;
}
.backdrop.show { opacity: 1; }
.sheet {
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
}
.sheet.show { transform: translateY(0); opacity: 1; }
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
.sheet-body { padding:12px; display:grid; gap:14px; }
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
.field .hint { margin-top:6px; font-size:12px; }
.row { display:flex; gap:8px; }
/* Radios */
.radios { display:grid; gap:8px; }
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
.radio input[type="radio"] { accent-color: #3b82f6; }
.radio[aria-disabled="true"] { opacity:.55; }
.tag {
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
border:1px solid var(--br); background: var(--dim); color: var(--fg);
}
/* Badge (version) already exists — keep it; this is just a reminder */
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
/* Buttons & inputs already set; ensure icon buttons look good */
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* apply to the glyph container you use */
.emo, .emo * {
font-family:
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
system-ui, sans-serif !important;
font-variant-emoji: emoji;
line-height: 1;
}
.emo img {
width: 1em;
height: 1em;
display: block;
}
#dewemoji-status {
float: right;
}
/* Settings tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--br);
margin-bottom: 12px;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
cursor: pointer;
background: var(--dim);
border: 1px solid var(--br);
border-bottom: none;
border-radius: 8px 8px 0 0;
font-weight: 600;
}
.tab:not(.active) {
opacity: 0.6;
}
.tab.active {
background: var(--bg);
color: var(--fg);
opacity: 1;
}
.tabpane { display: none; }
.tabpane.active { display: block; }
/* Tone palette theming */
.theme-light, :root.theme-light {
--c-bg: var(--bg, #ffffff);
--c-chip: var(--dim, #f3f4f6);
--c-border: var(--br, #e5e7eb);
}
.theme-dark, :root.theme-dark {
--c-bg: var(--bg, #0f172a);
--c-chip: var(--dim, #111827);
--c-border: var(--br, #374151);
}
/* Accent color for focus/selection rings */
.theme-light, :root.theme-light { --accent: #3b82f6; }
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
/* Tone palette buttons (popover + settings) */
.tone-btn, .tone-chip {
border: 1px solid var(--c-border);
background: var(--c-chip);
}
.tone-btn.selected, .tone-chip.selected {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent) !important;
}
.tone-btn:focus-visible, .tone-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ===== Settings sheet polish ===== */
.sheet { max-width: 540px; }
.sheet-head {
position: sticky; top: 0; background: var(--bg);
z-index: 2; border-bottom: 1px solid var(--br);
}
.sheet-head h3 { margin: 0; }
.sheet-body { padding-top: 10px; }
.field { margin: 12px 0 16px; }
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
/* Compact rows */
.row {
display: grid;
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
gap: 8px;
align-items: center;
}
.row + .row { margin-top: 6px; }
/* Inputs and small buttons */
.inp { padding: 6px 10px; }
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
.btn.ghost { background: transparent; border: 1px solid var(--br); }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
.link:hover { text-decoration: underline; }
/* Tabs = segmented control */
.tabs {
background: var(--dim); padding: 4px; border-radius: 10px;
display: inline-flex; gap: 4px; border: 1px solid var(--br);
margin-bottom: 12px;
}
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
/* License subtext wraps neatly */
#license-status { display: inline-block; margin-left: 8px; }
/* Tone palette spacin.g + buttons */
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
.tone-chip {
min-width: 40px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
}
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* Toast a tad higher so it doesnt overlap sheet */
.toast { bottom: 18px; }
/* Make the top controls a proper stacking context above the grid */
.topbar, .filters-row {
position: sticky; /* or relative if not sticky */
z-index: 5;
background: var(--c-bg, #0f172a); /* ensure it has a solid bg in dark & light */
}
/* Or ensure the select dropdown sits above adjacent icons */
select#sub {
position: relative;
z-index: 6;
}
div.card.active {
background-color: var(--active)!important;
}
.tone-row {
grid-column: 1 / -1;
background-color: var(--active);
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
border-radius: 12px;
padding: 12px;
display: grid;
grid-template-columns: repeat(5, minmax(0px, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
height: 56px;
border-radius: 10px;
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
background: var(--c-bg, #f3f4f6);
display: flex;
align-items: center;
justify-content: center;
font-size: x-large;
}
/* ===== APK companion overrides (extension-like, denser) ===== */
html {
min-height: 100%;
}
body {
height: auto;
min-height: 100%;
padding-top: max(0px, env(safe-area-inset-top));
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.hdr {
padding-top: calc(10px + env(safe-area-inset-top) * 0.25);
}
.grid {
grid-template-columns: repeat(auto-fill, minmax(68px, 1fr));
gap: 6px;
align-content: start;
min-height: calc(100vh - 170px);
background: var(--bg);
}
body.overlay-mode .grid {
min-height: 0;
}
html.overlay-mode,
body.overlay-mode {
min-height: 0;
height: auto;
}
body.overlay-mode {
padding-top: 0;
padding-bottom: 0;
}
body.overlay-mode .hdr,
body.overlay-mode .ft {
position: static;
}
body.overlay-mode #theme {
display: none !important;
}
body.overlay-mode #settings-sheet,
body.overlay-mode #sheet-backdrop {
display: none !important;
}
body.overlay-mode {
font-size: 15px;
}
body.overlay-mode .bar {
gap: 8px;
}
body.overlay-mode .filters {
gap: 8px;
padding: 10px 12px;
}
body.overlay-mode .inp,
body.overlay-mode .sel {
min-height: 48px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body.overlay-mode .btn {
min-height: 46px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body.overlay-mode .btn.icon {
width: 46px;
height: 46px;
min-height: 46px;
padding: 0;
font-size: 18px;
}
body.overlay-mode .btn.w {
min-width: 124px;
}
body.overlay-mode .ft {
padding: 12px;
gap: 10px;
}
body.overlay-mode .sheet {
left: 8px;
right: 8px;
bottom: 8px;
max-width: none;
border-radius: 16px;
box-shadow: 0 16px 44px rgba(0,0,0,.28);
}
body.overlay-mode .sheet-head {
padding: 14px;
}
body.overlay-mode .sheet-head h3 {
font-size: 20px;
}
body.overlay-mode .sheet-body {
padding: 14px;
gap: 16px;
}
body.overlay-mode .field {
margin: 14px 0 18px;
}
body.overlay-mode .field .lbl {
margin-bottom: 8px;
}
body.overlay-mode .field .hint,
body.overlay-mode .diagbox,
body.overlay-mode .muted {
font-size: 13px;
line-height: 1.35;
}
body.overlay-mode .diagbox {
padding: 12px;
border-radius: 10px;
}
body.overlay-mode .row,
body.overlay-mode .row-inline,
body.overlay-mode .row-wrap {
gap: 10px;
}
body.overlay-mode .row-inline {
align-items: stretch;
}
body.overlay-mode .row-wrap .btn,
body.overlay-mode .row-inline .btn {
min-height: 46px;
}
body.overlay-mode #account-connect-form .row-block .inp,
body.overlay-mode #account-connected .row-inline .btn {
min-height: 50px;
}
body.overlay-mode .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body.overlay-mode .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body.overlay-mode .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .tone-pref-group {
gap: 10px;
}
body.overlay-mode .tone-swatch {
width: 42px;
height: 42px;
}
body.overlay-mode .tone-circle {
width: 28px;
height: 28px;
}
body.overlay-mode .bubble-grid {
gap: 10px;
}
body.overlay-mode .bubble-kv {
padding: 10px;
border-radius: 10px;
}
body.overlay-mode .bubble-kv > span:last-child {
font-size: 14px;
}
body.overlay-mode .tone-row {
gap: 8px;
padding: 8px;
}
body.overlay-mode .tone-option {
min-height: 48px;
font-size: 24px;
}
@media (pointer: coarse), (max-width: 900px) {
body:not(.overlay-mode) .inp,
body:not(.overlay-mode) .sel {
min-height: 46px;
padding: 10px 12px;
border-radius: 12px;
font-size: 16px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn {
min-height: 44px;
padding: 10px 14px;
border-radius: 12px;
font-size: 15px;
line-height: 1.2;
}
body:not(.overlay-mode) .btn.icon {
width: 44px;
height: 44px;
min-height: 44px;
padding: 0;
font-size: 18px;
}
body:not(.overlay-mode) .filters {
gap: 8px;
padding: 10px 10px;
}
body:not(.overlay-mode) .bar {
gap: 8px;
}
body:not(.overlay-mode) .ft {
gap: 10px;
padding: 12px 10px;
}
body:not(.overlay-mode) .sheet {
border-radius: 14px;
}
body:not(.overlay-mode) .sheet-head {
padding: 14px;
}
body:not(.overlay-mode) .sheet-head h3 {
font-size: 20px;
}
body:not(.overlay-mode) .sheet-body {
padding: 14px;
gap: 16px;
}
body:not(.overlay-mode) .field {
margin: 14px 0 18px;
}
body:not(.overlay-mode) .row,
body:not(.overlay-mode) .row-inline,
body:not(.overlay-mode) .row-wrap {
gap: 10px;
}
body:not(.overlay-mode) .row-inline {
align-items: stretch;
}
body:not(.overlay-mode) .tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border-radius: 12px;
}
body:not(.overlay-mode) .tab {
min-height: 44px;
padding: 10px 8px;
border-radius: 10px;
font-size: 15px;
}
body:not(.overlay-mode) .radio {
min-height: 44px;
padding: 10px;
border-radius: 10px;
}
body:not(.overlay-mode) #account-connect-form .row-block .inp,
body:not(.overlay-mode) #account-connected .row-inline .btn {
min-height: 50px;
}
body:not(.overlay-mode) .bubble-kv {
padding: 10px;
border-radius: 10px;
}
}
.card {
min-height: 72px;
padding: 8px 6px;
gap: 4px;
border-radius: 10px;
position: relative;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card * {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.card .emo {
font-size: 24px;
}
.card .nm {
font-size: 10px;
line-height: 1.15;
max-width: 100%;
}
body.emoji-names-hidden .card {
min-height: 58px;
gap: 2px;
padding-top: 7px;
padding-bottom: 7px;
}
body.emoji-names-hidden .card .nm {
display: none;
}
body.emoji-names-hidden .card .emo {
font-size: 28px;
}
.btn.w {
min-width: 110px;
}
.ft {
padding-bottom: calc(10px + env(safe-area-inset-bottom) * 0.5);
background: var(--bg);
}
.sheet {
bottom: calc(12px + env(safe-area-inset-bottom));
}
.row-inline {
display: flex;
grid-template-columns: none;
align-items: center;
}
.row-wrap {
display: flex;
grid-template-columns: none;
flex-wrap: wrap;
}
.row-block {
display: block;
grid-template-columns: none;
}
.row-block .inp {
width: 100%;
}
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
.tone-pref-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.tone-swatch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
cursor: pointer;
}
.tone-swatch input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
cursor: pointer;
}
.tone-circle {
display: block;
width: 24px;
height: 24px;
border-radius: 999px;
border: 1px solid var(--br);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.08);
}
.tone-default {
background:
radial-gradient(circle at 50% 50%, transparent 0 28%, rgba(255,255,255,.7) 29% 34%, transparent 35%),
linear-gradient(180deg, #d7e0eb 0%, #a4b2c3 100%);
}
.tone-1 { background: #f7d7c4; }
.tone-2 { background: #e8bf95; }
.tone-3 { background: #c68642; }
.tone-4 { background: #8d5524; }
.tone-5 { background: #5e3b22; }
.tone-swatch input:checked + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
border-color: #60a5fa;
}
.tone-swatch input:focus-visible + .tone-circle {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
.bubble-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0 10px;
}
.bubble-kv {
display: grid;
gap: 4px;
border: 1px solid var(--br);
background: var(--bg);
border-radius: 8px;
padding: 8px;
min-width: 0;
}
.bubble-kv > span:last-child {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bubble-kv .ok { color: #059669; }
.bubble-kv .warn { color: #d97706; }
.bubble-kv .bad { color: #dc2626; }
.alert {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 10px;
}
.alert strong {
display: block;
margin-bottom: 2px;
}
.alert.error {
border-color: rgba(220, 38, 38, .35);
}
.alert.warn {
border-color: rgba(217, 119, 6, .35);
}
.autoload-sentinel {
width: 100%;
height: 1px;
}
.card.private {
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, .35);
}
.card.private .nm::after {
content: " · private";
color: #3b82f6;
}
.card.toneable::after {
content: "";
position: absolute;
top: 5px;
right: 5px;
width: 5px;
height: 5px;
border-radius: 999px;
background: #60a5fa;
opacity: 0.9;
}
.tone-row {
grid-column: 1 / -1;
border: 1px solid var(--br);
background: var(--dim);
border-radius: 12px;
padding: 8px;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
align-items: center;
}
.tone-option {
min-height: 42px;
border-radius: 10px;
border: 1px solid var(--br);
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.tone-option.selected {
box-shadow: inset 0 0 0 2px #60a5fa;
border-color: #60a5fa;
}
.tone-option.label {
font-size: 12px;
font-weight: 600;
}
.tone-note {
grid-column: 1 / -1;
font-size: 12px;
color: var(--mut);
padding: 2px 2px 0;
}
@media (max-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 5px;
padding: 8px;
}
.card {
min-height: 64px;
padding: 6px 4px;
}
.card .emo {
font-size: 22px;
}
.card .nm {
font-size: 9px;
}
body.emoji-names-hidden .card {
min-height: 52px;
padding-top: 6px;
padding-bottom: 6px;
}
body.emoji-names-hidden .card .emo {
font-size: 25px;
}
.bubble-grid {
grid-template-columns: 1fr;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.tab {
width: 100%;
text-align: center;
}
}
@media (max-width: 420px) {
.card {
min-height: 54px;
gap: 2px;
}
.card .emo {
font-size: 20px;
}
.card .nm {
font-size: 8px;
}
body.emoji-names-hidden .card .emo {
font-size: 24px;
}
.tone-row {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
padding: 6px;
}
.tone-option {
min-height: 38px;
font-size: 20px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,182 @@
<!doctype html>
<html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Dewemoji</title>
<style>
html, body { height: 100%; margin: 0; font-family: system-ui, -apple-system, sans-serif; background: #0b1220; color: #fff; }
.wrap { display: grid; place-items: center; height: 100%; padding: 24px; text-align: center; }
.card { max-width: 460px; }
h1 { margin: 0 0 8px; font-size: 24px; }
p { opacity: 0.85; line-height: 1.5; }
</style>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0b1220" />
<link rel="preconnect" href="https://twemoji.maxcdn.com" crossorigin />
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
<link href="app.css" rel="stylesheet" />
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Dewemoji</h1>
<p>If this screen appears, the app could not load <code>https://dewemoji.com</code>. Check internet connection and try again.</p>
<body class="theme-dark">
<header class="hdr">
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Guest</span></h1>
<div class="bar">
<input id="q" class="inp" type="search" placeholder="Search (e.g. love)" aria-label="Search emojis" />
<button id="clear" class="btn icon" title="Clear" aria-label="Clear"></button>
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
</div>
</div>
</header>
<section class="filters">
<select id="cat" class="sel" aria-label="Category filter">
<option value="">All categories</option>
</select>
<select id="sub" class="sel" aria-label="Subcategory filter" disabled>
<option value="">All subcategories</option>
</select>
</section>
<main id="list" class="grid" aria-live="polite"></main>
<div id="autoload-sentinel" class="autoload-sentinel" aria-hidden="true"></div>
<footer class="ft">
<button id="more" class="btn w">Load more</button>
<span id="count" class="muted nowrap">0 items</span>
<span id="ver" class="badge nowrap">APK</span>
</footer>
<div id="sheet-backdrop" class="backdrop" hidden></div>
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="sheet-head">
<h3 id="settings-title">Settings</h3>
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close"></button>
</div>
<div class="sheet-body">
<div class="tabs" role="tablist" aria-label="Settings tabs">
<button class="tab active" data-tab="general" role="tab" aria-selected="true">General</button>
<button class="tab" data-tab="account" role="tab" aria-selected="false">Account</button>
<button class="tab" data-tab="bubble" role="tab" aria-selected="false">Bubble</button>
</div>
<section id="tab-general" class="tabpane active" role="tabpanel">
<div class="field">
<label class="lbl">Instant Access</label>
<p class="hint muted">Tap an emoji to copy. Then switch back and paste in the app you are typing in.</p>
</div>
<div class="field">
<label class="lbl">Current Session</label>
<div class="diagbox" id="session-summary">Guest mode. Public keywords only.</div>
</div>
<div class="field">
<label class="lbl">Grid display</label>
<label class="radio row-inline" for="show-emoji-names-toggle">
<input id="show-emoji-names-toggle" type="checkbox" />
<span>Show emoji names under each emoji</span>
</label>
<p class="hint muted">Off by default for larger emoji and faster scanning.</p>
</div>
<div class="field">
<label class="lbl">Skin tone</label>
<label class="radio row-inline" for="tone-lock-toggle">
<input id="tone-lock-toggle" type="checkbox" />
<span>Use preferred skin tone on tap (tone lock)</span>
</label>
<div class="row row-block" style="margin-top:8px;">
<div id="preferred-skin-tone-radios" class="tone-pref-group" role="radiogroup" aria-label="Preferred skin tone">
<label class="tone-swatch" title="Default (no tone)">
<input type="radio" name="preferredSkinTone" value="0" aria-label="Default (no tone)" />
<span class="tone-circle tone-default" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Light skin tone">
<input type="radio" name="preferredSkinTone" value="1" aria-label="Light skin tone" />
<span class="tone-circle tone-1" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-light skin tone">
<input type="radio" name="preferredSkinTone" value="2" aria-label="Medium-light skin tone" />
<span class="tone-circle tone-2" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium skin tone">
<input type="radio" name="preferredSkinTone" value="3" aria-label="Medium skin tone" />
<span class="tone-circle tone-3" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Medium-dark skin tone">
<input type="radio" name="preferredSkinTone" value="4" aria-label="Medium-dark skin tone" />
<span class="tone-circle tone-4" aria-hidden="true"></span>
</label>
<label class="tone-swatch" title="Dark skin tone">
<input type="radio" name="preferredSkinTone" value="5" aria-label="Dark skin tone" />
<span class="tone-circle tone-5" aria-hidden="true"></span>
</label>
</div>
</div>
<p class="hint muted">Long press an emoji with skin tones to choose a tone quickly.</p>
</div>
<div class="field">
<label class="lbl">Refresh catalog</label>
<div class="row row-inline">
<button id="refresh" class="btn">Refresh</button>
<span id="api-status" class="muted">Loading…</span>
</div>
</div>
</section>
<section id="tab-account" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Account</label>
<div id="account-connect-form">
<div class="row row-block">
<input id="account-email" class="inp" placeholder="Email" type="email" autocomplete="username" />
</div>
<div class="row row-block" style="margin-top:6px;">
<input id="account-password" class="inp" placeholder="Password" type="password" autocomplete="current-password" />
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-login" class="btn">Connect</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span id="account-status" class="muted">Not connected. Public keywords only.</span>
</div>
</div>
<div id="account-connected" style="display:none;">
<div class="row row-inline">
<span id="account-greeting" class="muted">Connected.</span>
</div>
<div class="row row-inline" style="margin-top:8px;">
<button id="account-logout" class="btn ghost">Logout</button>
</div>
<div class="row row-inline" style="margin-top:6px;">
<span class="muted">Private keyword matches appear in search when available.</span>
</div>
</div>
</div>
</section>
<section id="tab-bubble" class="tabpane" role="tabpanel">
<div class="field">
<label class="lbl">Floating bubble (quick search + copy)</label>
<p class="hint muted">Use Dewemoji bubble to quickly search and copy emoji while using other apps. Paste manually in the app youre typing in.</p>
</div>
<div class="bubble-grid">
<div class="bubble-kv"><span class="muted">Platform</span><span id="bubble-platform-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Overlay permission</span><span id="bubble-overlay-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Notifications</span><span id="bubble-notify-status">Checking…</span></div>
<div class="bubble-kv"><span class="muted">Bubble service</span><span id="bubble-running-status">Checking…</span></div>
</div>
<div class="field">
<div class="row row-wrap">
<button id="bubble-enable-btn" class="btn">Enable bubble</button>
<button id="bubble-disable-btn" class="btn ghost">Disable bubble</button>
</div>
<div class="row row-wrap" style="margin-top:8px;">
<button id="bubble-overlay-settings-btn" class="btn ghost">Grant overlay permission</button>
<button id="bubble-notify-settings-btn" class="btn ghost">Open notification settings</button>
</div>
<div class="diagbox" id="bubble-help" style="margin-top:8px;">Bubble is off by default. It opens Dewemoji for quick search and copy.</div>
</div>
</section>
</div>
</aside>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="twemoji-lite.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
(() => {
const root = window;
if (root?.twemoji?.convert?.toCodePoint) return;
function toCodePoint(unicodeSurrogates, sep = '-') {
const r = [];
let c = 0;
let p = 0;
let i = 0;
const s = String(unicodeSurrogates || '');
while (i < s.length) {
c = s.charCodeAt(i++);
if (p) {
r.push((0x10000 + ((p - 0xD800) * 0x400) + (c - 0xDC00)).toString(16));
p = 0;
} else if (c >= 0xD800 && c <= 0xDBFF) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join(sep);
}
root.twemoji = root.twemoji || {};
root.twemoji.convert = root.twemoji.convert || {};
root.twemoji.convert.toCodePoint = toCodePoint;
})();