Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.9.0",
"version": "1.9.1",
"private": true,
"scripts": {
"dev": "webpack --watch",
Expand All @@ -17,6 +17,7 @@
"json-loader": "0.5.7",
"postcss": "8.4.16",
"postcss-loader": "7.0.1",
"postcss-prefix-selector": "2.1.1",
"postcss-preset-env": "7.8.0",
"raw-loader": "4.0.2",
"sass": "1.54.3",
Expand All @@ -42,4 +43,4 @@
"vue": "3.5.17",
"vue-router": "4.5.1"
}
}
}
55 changes: 51 additions & 4 deletions postcss.config.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,56 @@
const tailwindcss = require('tailwindcss');
const tailwindcss = require("tailwindcss");
const prefixSelector = require("postcss-prefix-selector");

// Confines all extension CSS (Tailwind preflight + utilities, lib-vue-components
// styles, SFC blocks) to descendants of `.subturtle-scope`. Without this, the
// universal selectors in Tailwind preflight (`*, ::before, ::after`, `button`,
// `img`, ...) bleed onto the host page and break YouTube's icon rendering and
// video grid layout.
const SCOPE = ".subturtle-scope";

module.exports = {
plugins: [
'postcss-preset-env',
"postcss-preset-env",
tailwindcss,
// ...(process.env.NODE_ENV === 'production' ? { cssnano: {} } : {})
prefixSelector({
prefix: SCOPE,
transform(prefix, selector, prefixedSelector) {
const trimmed = selector.trim();

// Idempotent guard: lib-vue-components CSS gets visited more than once
// by the loader chain, which would otherwise produce double/triple
// prefixes (`.subturtle-scope .subturtle-scope ...`) that never match.
if (trimmed.startsWith(prefix)) {
const charAfter = trimmed[prefix.length];
if (charAfter === undefined || /[\s.:>+~\[]/.test(charAfter)) {
return selector;
}
}

// Drop `body` rules — they target the host page (background,
// font-family, margin reset). Mapping them onto our wrapper paints
// a visible frame around the host UI.
if (trimmed === "body") {
return ":not(*)";
}

// Map root-level selectors onto the scope so CSS custom properties
// and typography defaults still cascade into the extension UI.
if (/^(html|:root)$/.test(trimmed)) {
return prefix;
}

// Tailwind class-based dark mode emits selectors like `.dark .foo`.
// Merge `.dark` with the prefix as a compound selector so a single
// `.subturtle-scope.dark` element activates dark utilities for all
// its descendants — without requiring a separate `.dark` ancestor
// inside the scope.
if (/^\.dark(?=[\s.:>+~\[]|$)/.test(trimmed)) {
return prefix + trimmed;
}

return prefixedSelector;
},
}),
],
};
};
56 changes: 48 additions & 8 deletions src/common/store/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,55 @@ export const useSettingsStore = defineStore("settings", () => {
}
}

function applyThemeToDOM(themeValue: Theme) {
document.documentElement.classList.remove("light", "dark");
let scopeObserver: MutationObserver | null = null;
let currentEffectiveTheme: "dark" | "light" = "dark";

function resolveTheme(themeValue: Theme): "dark" | "light" {
if (themeValue === "auto") {
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
document.documentElement.classList.add(prefersDark ? "dark" : "light");
} else {
document.documentElement.classList.add(themeValue);
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
return themeValue;
}

function applyToScopeElement(el: Element) {
el.classList.remove("light", "dark");
el.classList.add(currentEffectiveTheme);
}

// The `dark` class lives on every `.subturtle-scope` element rather than
// `<html>`, because postcss-prefix-selector rewrites Tailwind's dark rules to
// the compound form `.subturtle-scope.dark ...` — so the same element must
// carry both classes for dark utilities to take effect.
function applyThemeToDOM(themeValue: Theme) {
currentEffectiveTheme = resolveTheme(themeValue);

document
.querySelectorAll(".subturtle-scope")
.forEach(applyToScopeElement);

// Vue teleports (e.g. WordSelectionRectangle, the YouTube caption
// container) mount `.subturtle-scope` wrappers after this initial pass,
// so an observer keeps later additions in sync with the active theme.
if (!scopeObserver && typeof MutationObserver !== "undefined") {
scopeObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
m.addedNodes.forEach((node) => {
if (!(node instanceof Element)) return;
if (node.classList?.contains("subturtle-scope")) {
applyToScopeElement(node);
}
node
.querySelectorAll?.(".subturtle-scope")
.forEach(applyToScopeElement);
});
}
});
scopeObserver.observe(document.body, {
childList: true,
subtree: true,
});
}
}

Expand Down
46 changes: 23 additions & 23 deletions src/console-crane/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { useConsoleCraneStore } from "./stores/console-crane";
import { RouterView, useRouter } from "vue-router";
import Modal from "./components/Modal.vue";
import { getSubturtleDashboardUrlWithToken } from "../common/static/global";
import { Button, IconButton } from "@codebridger/lib-vue-components/elements";
import { Button, IconButton, App } from "@codebridger/lib-vue-components";
import { watch, onMounted, onUnmounted, ref, computed } from "vue";
import { analytic } from "../plugins/mixpanel";

Expand Down Expand Up @@ -60,30 +60,30 @@ onUnmounted(() => {
</script>

<template>
<!-- <teleport to="body"> -->
<div id="subturtle-console-crane" ref="rootRef">
<modal v-model="store.isActive" v-slot="{ height, width }">
<div class="flex flex-col py-6" :style="{ width: width + 'px', height: height + 'px' }">
<!-- Header: always visible -->
<section class="flex flex-row-reverse justify-between mx-12 mt-6 shrink-0">
<div class="flex space-x-2 items-center w-full">
<template v-if="isOnSettingsPage">
<IconButton size="sm" rounded="full" icon="i-mdi-arrow-left" @click="store.goBack" />
</template>
<template v-else>
<IconButton size="sm" rounded="full" icon="i-mdi-cog" @click="openSettings" />
</template>
<div class="flex-1"></div>
<Button color="info" rounded="full" label="Go to Dashboard" @click="goToDashboard" />
</div>
</section>
<App>
<modal v-model="store.isActive" v-slot="{ height, width }">
<div class="flex flex-col py-6" :style="{ width: width + 'px', height: height + 'px' }">
<!-- Header: always visible -->
<section class="flex flex-row-reverse justify-between mx-12 mt-6 shrink-0">
<div class="flex space-x-2 items-center w-full">
<template v-if="isOnSettingsPage">
<IconButton size="sm" rounded="full" icon="i-mdi-arrow-left" @click="store.goBack" />
</template>
<template v-else>
<IconButton size="sm" rounded="full" icon="i-mdi-cog" @click="openSettings" />
</template>
<div class="flex-1"></div>
<Button color="info" rounded="full" label="Go to Dashboard" @click="goToDashboard" />
</div>
</section>

<!-- Body: scrollable -->
<div class="flex-1 overflow-y-auto w-full">
<router-view class="w-full flex-1" />
<!-- Body: scrollable -->
<div class="flex-1 overflow-y-auto w-full">
<router-view class="w-full flex-1" />
</div>
</div>
</div>
</modal>
</modal>
</App>
</div>
<!-- </teleport> -->
</template>
7 changes: 3 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import "./trusted-types-polyfill";

log("Using version", VERSION);

import "./animation.scss";
import "./tailwind.css";

Expand All @@ -12,7 +10,6 @@ import ConsoleCrane from "./console-crane/index.vue";

import { netflix } from "./subtitle/web_netflix/initializer";
import { youtube } from "./subtitle/web_youtube/initializer";
import { msTeam } from "./subtitle/ms-team/initializer";
import { AppInitializer } from "./common/types/general.type";
import { cleanText } from "./common/helper/text";
import { analytic } from "./plugins/mixpanel";
Expand All @@ -26,13 +23,15 @@ import {
import { loginWithLastSession } from "./plugins/modular-rest";
import { useSettingsStore } from "./common/store/settings";

log("Using version", VERSION);

let vueApp!: App;
let appInitializer!: AppInitializer;
let initialized = false;

// Select an app initializer from existing modules
//
[youtube, netflix, msTeam].forEach((item) => {
[youtube, netflix].forEach((item) => {
if (location.hostname.includes(item.website.host)) {
appInitializer = item;
}
Expand Down
6 changes: 3 additions & 3 deletions src/subtitle/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Subtitle Platform

## Purpose
Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overlays for every supported surface (Netflix, YouTube, MS Teams, future providers). Each integration shares the same learning experience—word selection, translation, and Console Crane—while adapting to the target site’s DOM and policies.
Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overlays for every supported surface (Netflix, YouTube, future providers). Each integration shares the same learning experience—word selection, translation, and Console Crane—while adapting to the target site’s DOM and policies.

### High-Level Flow
1. **Initializer (per surface)** mounts a Vue app once the host caption container appears.
Expand All @@ -12,7 +12,7 @@ Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overla
## Directory Map
| Path | Description |
| --- | --- |
| `ms-team`, `web_youtube`, `web_netflix` | Production integrations. Each folder contains `Index.vue`, `initializer.ts`, `static.ts`, and any extra assets unique to that platform. |
| `web_youtube`, `web_netflix` | Production integrations. Each folder contains `Index.vue`, `initializer.ts`, `static.ts`, and any extra assets unique to that platform. |
| `_support-template` | Boilerplate for new providers. Copy this folder when bootstrapping another site—then adjust selectors, DOM strategy, and policies. |
| `components/` | Cross-surface Vue components. `specific/Word.vue`, `WordSelectionRectangle.vue`, `TranslatedPhrase.vue`, `SelectionAnchor.vue`, and `SvgLoader.vue` compose the interactive overlay. |
| `components/components.ts` | Barrel file for exporting shared components to site entries. |
Expand All @@ -23,7 +23,7 @@ Content scripts under `src/subtitle` power Subturtle’s on-page subtitle overla
- **Marker Store (`src/stores/marker.ts`)** – central authority for marked words, hover state, and auto-clear timers (2.5s single, 5s multi). Integrations should only invoke store actions; never manage timers inside view components.
- **Word Rendering** – `Subtitle.vue` (per surface) splits caption text into `<Word>` components, each wired to `markerStore` for hover/selection state.
- **Selection Rectangle** – `WordSelectionRectangle.vue` reads `markerStore` positions to draw DOM overlays that follow host captions even while they animate.
- **Translation Bubble** – `TranslatedPhrase.vue` shows the latest translated text. Surfaces can offset its placement via local constants (e.g., `translationSpacingPx` in MS Teams).
- **Translation Bubble** – `TranslatedPhrase.vue` shows the latest translated text. Surfaces can offset its placement via local constants.
- **Console Crane** – `src/console-crane` is mounted alongside subtitle overlays to display word details; surfaces only need to ensure the crane container stays connected and receives context.

## Adding a New Surface
Expand Down
3 changes: 3 additions & 0 deletions src/subtitle/_support-template/initializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export const initConfig: AppInitializer = {
// @TODO: Find a better way to do this

appDiv.id = "subturtle-app";
// `subturtle-scope` is the anchor the postcss prefix-selector targets;
// every Subturtle-rendered subtree must carry this class to receive styles.
appDiv.classList.add("subturtle-scope");
appDiv.style.position = "relative";
appDiv.style.zIndex = "9999";

Expand Down
6 changes: 4 additions & 2 deletions src/subtitle/components/specific/WordSelectionRectangle.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
<template>
<!-- Teleport target is `body`, which is outside `#subturtle-app`, so this
subtree must carry its own `.subturtle-scope` for prefixed CSS to apply. -->
<Teleport to="body">
<template v-if="isVisible">
<div v-if="isVisible" class="subturtle-scope">
<!-- Multiple rectangles, one per line -->
<div
v-for="(lineRect, index) in lineRectangles"
Expand Down Expand Up @@ -35,7 +37,7 @@
>
<span class="close-icon">×</span>
</div>
</template>
</div>
</Teleport>
</template>

Expand Down
Loading
Loading