noraneko · drops

dl.f3liz.casa/drop を読んでいます

splitview

分割ビューを二枚から四枚へ(並べかたを絵から選ぶ、タブを落として足す、境をドラッグ、ペインを閉じる/ひとりにする)

uuid 53075336-7e4d-47d8-9359-5b2ad96f8351

install

Press inside noraneko. It catches the link, opens its settings with this uuid, downloads the xpi, checks the sha256 and the registry's signature, and shows you everything below again before anything runs.

install in noraneko In another browser this is a plain link to the xpi. Stock Firefox will refuse it as unverified; paste the uuid into about:nora:settings in noraneko instead.

who

gh/nyanrus

what is served

splitview · splitview@noraneko.app · version 1.0.2 · 49824 bytes sha256 753ee5f63e8b6122cae5d92d7c2f5be389bd609ae2a8138c931cbe49c774a3ad built from drops/splitview/src @ 59b96c854c (2026-09-14T07:24:00Z) served at https://dl.f3liz.casa/drop/53075336-7e4d-47d8-9359-5b2ad96f8351/

in the registry

drops/splitview/ @ 59b96c854c — この版が出てきた木 actors: splitview

the source, as it is

These come from source.json next to the manifest — the same text the xpi carries under source/, and dl checks its sha256 against the signed manifest before handing it over. Not a copy of the repository: what you read and what runs came out of the same build. 15 files; press one to open it.

_shared/contentRuntime.ts · 50 lines
// SPDX-License-Identifier: MPL-2.0

// Shared page-side runtime. Bundled into every actor's content.js.
//
// content.js is loaded by the generated child.sys.mjs (a JSWindowActorChild) with
// Services.scriptloader.loadSubScript(url, scope). `scope` puts `window`,
// `document`, `exportFunction` and `__nora` on the scope chain, so the actor's
// content hook can use `window` / `document` as free variables just like a
// content script would, while actually running with the child actor's
// privileges in the page's process. `__nora` is the bridge to the parent
// (sendQuery) and to the page (exportFunction).

import type { ActorMeta, ContentCtx, ContentHook, Ops } from "./defineActor.ts";
import { makeIo } from "./io.ts";

// Provided on the scope chain by child.sys.mjs.
declare const __nora: {
  call(method: string, args: unknown[]): Promise<unknown>;
  expose(funcs: Record<string, (...args: any[]) => unknown>): void;
  onDestroy(fn: () => void): void;
  base: string;
  tsubaki: Ops | undefined;
};

export function runContent(_meta: ActorMeta, hook: ContentHook): void {
  const parent = new Proxy(
    {},
    {
      get(_target, method: string) {
        return (...args: unknown[]) => __nora.call(method, args);
      },
    },
  ) as any;

  const ctx: ContentCtx = {
    dev: import.meta.env.MODE === "dev",
    expose(funcs) {
      __nora.expose(funcs);
    },
    onDestroy(fn) {
      __nora.onDestroy(fn);
    },
    io: makeIo((fn) => __nora.onDestroy(fn)),
    base: __nora.base,
    ops: __nora.tsubaki,
  };

  hook(parent, ctx);
}
_shared/defineActor.ts · 118 lines
// SPDX-License-Identifier: MPL-2.0

// Authoring surface for an "actor": a small xpi whose page side is a
// JSWindowActor (the same shape as Firefox's own about:newtab add-on). Each
// actor lives in one file that exports three things by name:
//
//   export const meta    = { id, namespace, matches, ... };
//   export const parent  = defineParent({ method(...) { ... } });  // main process (JSWindowActorParent)
//   export const content = defineContent((parent, ctx) => { ... }); // page side (run by JSWindowActorChild)
//
// The page side is NOT a WebExtension content script: content scripts are not
// injected into about:* / chrome:* documents in stock Firefox (see README,
// "addon 式が駄目だった理由"). The child actor loads content.js into the page's
// process with `window` / `document` on the scope chain, so the hook is written
// the same way, but it runs with the child actor's privileges.
//
// Named exports (rather than one nested object) are deliberate: the build
// bundles `parent` and `content` into separate outputs, and named exports let
// the bundler tree-shake each side so the content bundle never drags in the
// parent's privileged code and vice versa.
//
// IMPORTANT: the module top level must be pure. Reference Firefox globals
// (Services, ChromeUtils, window, exportFunction, browser) only *inside* method
// or hook bodies — the build imports this file under Deno to read `meta`, and
// top-level use of those globals would throw there.

export interface ActorMeta {
  /** Extension id, e.g. "settings-bridge@noraneko.app". */
  id: string;
  /** Extension version. */
  version: string;
  /** Experiment-API namespace, e.g. "noraSettings". Also the message channel. */
  namespace: string;
  /** Match patterns for the pages the content hook runs in (JSWindowActor `matches`). */
  matches: string[];
  /**
   * When the content hook runs. Maps to a child actor event:
   * document_start → DOMDocElementInserted, document_end → DOMContentLoaded,
   * document_idle → load. Defaults to "document_end".
   */
  runAt?: "document_start" | "document_end" | "document_idle";
  /**
   * JSWindowActor name. Defaults to "Nora" + PascalCase(<dir>), e.g. NoraNewtab.
   * A drop with the same actor name replaces the built-in one (unregister → register).
   */
  actor?: string;
  /** Legacy JSActor (BrowserGlue) this one replaces; it is unregistered when this actor is registered. */
  replaces?: string;
}

import type { Io } from "./io.ts";

type ParentMethods = Record<string, (...args: any[]) => unknown>;

export interface ContentCtx {
  /** True in dev builds (import.meta.env.MODE === "dev"). */
  dev: boolean;
  /** Expose functions on the page window (via exportFunction). */
  expose(funcs: Record<string, (...args: any[]) => unknown>): void;
  /**
   * Run when this actor goes away in this window (the drop is removed or
   * replaced). Put back what the hook changed: DOM, observers, listeners.
   */
  onDestroy(fn: () => void): void;
  /**
   * Place things through these (DOM, styles, listeners, pref observers) and
   * they are taken out again by themselves: each verb puts its way back on
   * the same ledger as onDestroy.
   */
  io: Io;
  /** This actor's own files: `${ctx.base}ops/x.tsubaki` and the like. */
  base: string;
  /**
   * The actor's logic in Tsubaki (when it ships wasm/): run a .tsubaki file of
   * its own with load("ops/x.tsubaki"), then call(name, ...args). Values cross
   * as ordinary JS values; a Tsubaki error is a JS Error. undefined without wasm/.
   *
   * Every verb answers with a Promise, because for an actor of the browser
   * WINDOW the logic lives a process away (wasm can't compile in the parent
   * process, so it runs in a hidden page of this window instead). A drop never
   * has to know which side it got.
   */
  ops: Ops | undefined;
}

export interface Ops {
  ready: Promise<void>;
  call(name: string, ...args: unknown[]): Promise<unknown>;
}

/** Proxy to the parent methods; each call is forwarded to the main process. */
type ParentProxy<T extends ParentMethods> = {
  [K in keyof T]: (...args: Parameters<T[K]>) => Promise<Awaited<ReturnType<T[K]>>>;
};

export type ContentHook<T extends ParentMethods = ParentMethods> = (
  parent: ParentProxy<T>,
  ctx: ContentCtx,
) => void;

// Identity helpers that pin types for authoring:
//
//   export const parent  = defineParent({ method(...) { ... } });
//   export const content = defineContent<typeof parent>((parent, ctx) => { ... });
//
// They are listed in the build's treeshake.manualPureFunctions, so an unused
// call (e.g. defineContent in the parent bundle) is dropped along with anything
// it references (birpc). Without that, the bundler would keep the call for its
// possible side effects and the unused side would not tree-shake.

/** Pins the parent-method types so the content hook's proxy is typed. */
export const defineParent = <T extends ParentMethods>(methods: T): T => methods;

/** Pins the content hook against the parent's method types. */
export const defineContent = <T extends ParentMethods>(
  hook: ContentHook<T>,
): ContentHook<T> => hook;
_shared/io.ts · 53 lines
// SPDX-License-Identifier: MPL-2.0

// The io layer of a content hook: every verb here *places* something in the
// window and, in the same breath, puts the way back on the ledger
// (ctx.onDestroy). When the drop is removed or replaced, the ledger is run in
// reverse and the window is as it was. A hook that only ever places through
// these verbs has no cleanup to write.

export interface Io {
  /** Insert a node and remember to remove it. */
  place(node: Node, at: { parent: Node } | { before: Node } | { after: Node }): void;
  /** Add a <style> to the document head and remember to remove it. */
  style(doc: Document, css: string): HTMLStyleElement;
  /** addEventListener, and remember the matching removeEventListener. */
  listen<K extends string>(
    target: EventTarget,
    type: K,
    fn: (ev: any) => void,
    options?: boolean | AddEventListenerOptions,
  ): void;
  /** Watch a pref and remember to stop watching. */
  pref(name: string, fn: () => void): void;
  /** Anything else that has to be put back: hand the way back to the ledger. */
  defer(fn: () => void): void;
}

export function makeIo(onDestroy: (fn: () => void) => void): Io {
  return {
    place(node, at) {
      if ("parent" in at) at.parent.appendChild(node);
      else if ("before" in at) (at.before as ChildNode).before(node);
      else (at.after as ChildNode).after(node);
      onDestroy(() => (node as ChildNode).remove());
    },
    style(doc, css) {
      const el = doc.createElementNS("http://www.w3.org/1999/xhtml", "style") as HTMLStyleElement;
      el.textContent = css;
      this.place(el, { parent: doc.head ?? doc.documentElement });
      return el;
    },
    listen(target, type, fn, options) {
      target.addEventListener(type, fn, options);
      onDestroy(() => target.removeEventListener(type, fn, options));
    },
    pref(name, fn) {
      const observer = () => fn();
      Services.prefs.addObserver(name, observer);
      onDestroy(() => Services.prefs.removeObserver(name, observer));
    },
    defer: onDestroy,
  };
}
splitview/actor.ts · 94 lines
// SPDX-License-Identifier: MPL-2.0

// splitview: 本体の分割ビューを、二枚から四枚へ。
//
// Firefox 155 は分割ビューを持っている。tabbrowser の API(addTabSplitView /
// showSplitViewPanels / tabpanels.splitViewPanels)は枚数を数えていない ── 三枚
// 渡せば三枚受け取る。止めているのは UI と CSS のほうで、並べかたを言う口が無く、
// 置き場所が column="0" と "1" の order しか用意されていない。
//
// だからこの drop がすることは、機能を足すというより、そこを開けること:
//
//   io/panels.ts  ペインの箱を grid に置き直す(芯。掴むのは setSplitViewActive 一つ)
//   io/grips.ts   境を持つ。動かしている最中に中身がついてくる
//   io/menu.ts    タブの右クリックから、三枚目・四枚目を足す / このペインを外す
//   io/picker.ts  並べかたを絵から選ぶ(Windows の、あの格子)
//   io/footer.ts  ペインの隅に、閉じる と ひとりにする
//   io/tabdrop.ts タブを落とす(ページの上=Vivaldi 式、タブの列の束の上)。
//                 掴んでいる間、分割ビューは畳まれずに出たままになる
//   io/groups.ts  タブグループと: グループをそのまま分割にする、境をその色に
//
// 窓に置いたものは全部 ctx.io を通っているので、外せば元の二枚の分割ビューが
// そのまま戻る(docs/LAYERS.md)。
//
// この actor は browser.xhtml そのものに効くので、content hook はブラウザの窓の
// 中で動く(window は ChromeWindow、gBrowser が手の届くところにある)。

import {
  defineContent,
  defineParent,
  type ActorMeta,
  type ContentCtx,
} from "../_shared/defineActor.ts";
import type { ChromeWindow } from "./chrome.ts";
import { STYLE } from "./style.ts";
import { makeGrid } from "./io/panels.ts";
import { makeGrips, type Grips } from "./io/grips.ts";
import { makePicker, type Picker } from "./io/picker.ts";
import { makeFooters, type Footers } from "./io/footer.ts";
import { addMenuRows } from "./io/menu.ts";
import { makeTabDrop } from "./io/tabdrop.ts";
import { makeGroups, type Groups } from "./io/groups.ts";
import { PREFS } from "./io/prefs.ts";

export const meta: ActorMeta = {
  id: "splitview@noraneko.app",
  version: "1.0.2",
  namespace: "noraSplitView",
  matches: ["chrome://browser/content/browser.xhtml"],
  runAt: "document_end",
};

export const parent = defineParent({});

export const content = defineContent<typeof parent>((_parent, ctx) => {
  main(ctx).catch((e) => console.error("[splitview] failed:", e));
});

async function main(ctx: ContentCtx): Promise<void> {
  const win = window as unknown as ChromeWindow;
  const doc = document;
  // popup の窓(window.open で開いたもの)にはタブの列も分割ビューも無い
  if ((doc.documentElement.getAttribute("chromehidden") ?? "").includes("toolbar")) return;
  await win.delayedStartupPromise;

  ctx.io.style(doc, STYLE);

  // 形が変わったら知りたいものが三つある(境、並べかたの絵、ペインの隅のボタン)。
  // 形を決めるのは grid のほうなので、先に名前だけ用意して、繋ぐ
  let grips: Grips | null = null;
  let picker: Picker | null = null;
  let footers: Footers | null = null;
  let groups: Groups | null = null;
  const grid = makeGrid(ctx.io, win, () => {
    grips?.update();
    picker?.update();
    footers?.update();
    groups?.update();
  });
  grips = makeGrips(ctx.io, win, grid);
  picker = makePicker(ctx.io, win, grid);
  footers = makeFooters(ctx.io, win, grid);
  groups = makeGroups(ctx.io, win);

  addMenuRows(ctx.io, win);
  makeTabDrop(ctx.io, win, grid);

  // 並べかたと境は pref に居る。about:config から書き換えても、別の窓で変えても、
  // その場で効く
  for (const name of PREFS) ctx.io.pref(name, () => grid.arrange());

  // この窓にもう分割ビューが出ているかもしれない(drop を入れた瞬間、再起動なしで)
  grid.arrange();
}
splitview/chrome.ts · 91 lines
// SPDX-License-Identifier: MPL-2.0

// 本体の分割ビューのうち、この drop が名前で呼ぶものだけ。窓のものは何でも
// 触れてしまうので、「触れる」ことと「触ると言った」ことを分けておきたい ──
// ここに書いていないものは、この drop は知らない。
//
// 出どころは chrome://browser/content/browser/tabbrowser/{tabbrowser,tabsplitview}.js と
// chrome://global/content/elements/tabbox.js(MozTabpanels)。

export interface XULTab extends Element {
  linkedPanel: string;
  linkedBrowser: { currentURI?: { spec?: string } } | null;
  /** そのタブが入っている分割ビュー。入っていなければ null */
  splitview: SplitViewWrapper | null;
  selected: boolean;
  pinned: boolean;
  label: string;
}

/** <tab-split-view-wrapper>。タブの列の中で、分割ビューのタブたちを束ねている */
export interface SplitViewWrapper extends Element {
  tabs: XULTab[];
  addTabs(tabs: XULTab[], options?: { isSessionRestore?: boolean }): void;
  unsplitTabs(trigger?: string | null): void;
  /** その束が入っているタブグループ。入っていなければ null */
  group: TabGroup | null;
}

/** <tab-group>。色は --tab-group-color として、その要素に載っている */
export interface TabGroup extends Element {
  id: string;
  color: string;
  label: string;
  tabs: XULTab[];
}

/** <tabgroup-menu id="tab-group-editor">。グループの名前と色を編める、あのパネル */
export interface TabGroupEditor extends Element {
  activeGroup: TabGroup | null;
}

/** <tabpanels id="tabbrowser-tabpanels">。ページの入っている箱たちの親 */
export interface Tabpanels extends Element {
  /** いま分割ビューに出ている panel の id。順番は左上から */
  splitViewPanels: string[];
  setSplitViewActive(active: boolean): void;
  /** 分割の外のタブへ移ったとき、ペインを畳む(class を外すだけ。寸法は残る) */
  suspendSplitViewPanels(tabs: XULTab[]): void;
  style: CSSStyleDeclaration;
}

export interface GBrowser {
  tabpanels: Tabpanels | null;
  tabContainer: Element;
  tabs: XULTab[];
  selectedTab: XULTab;
  selectedTabs: XULTab[];
  activeSplitView: SplitViewWrapper | null;
  showSplitViewPanels(tabs: XULTab[]): void;
  addTabSplitView(tabs: XULTab[], options?: { insertBefore?: XULTab | null }): SplitViewWrapper | null;
  addTrustedTab(url: string): XULTab;
  removeTab(tab: XULTab): void;
  /** タブを分割ビューの束へ。束の数えている中身も一緒に動く */
  moveTabToSplitView(tab: XULTab, wrapper: SplitViewWrapper, at?: number): void;
  /** タブの引っ越しを、本体の作法(選択やイベントの後始末)ごと包む */
  handleTabMove(element: Element, move: () => void): void;
  /** 分割ビューの束を、まるごとタブグループへ */
  moveSplitViewToExistingGroup(wrapper: SplitViewWrapper, group: TabGroup): void;
  /** そのタブを、新しい窓へ引っ越す */
  replaceTabWithWindow(tab: XULTab): void;
  getTabForBrowser(browser: unknown): XULTab | null;
}

export interface ChromeWindow extends Window {
  gBrowser: GBrowser;
  TabContextMenu?: { contextTab: XULTab | null };
  delayedStartupPromise: Promise<void>;
}

/** その窓の分割ビューにいま出ているペインたち(panel の要素。無ければ空) */
export function panesOf(win: ChromeWindow): HTMLElement[] {
  const tabpanels = win.gBrowser?.tabpanels;
  if (!tabpanels) return [];
  const out: HTMLElement[] = [];
  for (const id of tabpanels.splitViewPanels) {
    const el = win.document.getElementById(id);
    if (el) out.push(el);
  }
  return out;
}
splitview/io/footer.ts · 85 lines
// SPDX-License-Identifier: MPL-2.0

// ペインの隅に、二つ。
//
// 本体はもう footer を持っている ── 分割ビューで「いま見ていないほう」のペインの
// 隅に、favicon とドメインと「…」が出る。足りないのは、そこから直接できることで、
// 「…」を開いてメニューから探すことになる。よく要るのは二つだけなので、隣に置く:
//
//   ひとりにする  そのペインだけを見る(分割を解いて、そのタブへ)
//   閉じる        そのタブを閉じる(二枚なら、本体が自分で分割を解く)
//
// 出るのはカーソルがそのペインに来たときだけ。ずっと見えていると、ページの隅に
// 常に何かが乗っていることになる。
//
// 本体の footer は「見ていないほう」にしか出ないので、見ているペインのぶんは
// CSS で hover のときだけ出す(style.ts)。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow, XULTab } from "../chrome.ts";
import type { PanelGrid } from "./panels.ts";

export interface Footers {
  update(): void;
}

const ALONE = "chrome://browser/skin/fullscreen.svg";
const CLOSE = "chrome://global/skin/icons/close.svg";

export function makeFooters(io: Io, win: ChromeWindow, grid: PanelGrid): Footers {
  return {
    update(): void {
      for (const pane of grid.panes()) {
        const footer = pane.querySelector("split-view-footer");
        if (!footer || footer.querySelector(".nora-split-act")) continue;
        // 本体の「…」の手前に。並びは 閉じる のほうが端に来ないようにする
        const more = footer.querySelector("toolbarbutton");
        const at = more ? { before: more } : { parent: footer };
        add(io, win, pane, at, ALONE, "Show only this pane", (tab) => alone(win, tab));
        add(io, win, pane, at, CLOSE, "Close this pane", (tab) => win.gBrowser.removeTab(tab));
      }
    },
  };
}

function add(
  io: Io,
  win: ChromeWindow,
  pane: Element,
  at: { before: Node } | { parent: Node },
  icon: string,
  tip: string,
  run: (tab: XULTab) => void,
): void {
  const button = win.document.createXULElement("toolbarbutton");
  button.className = "nora-split-act";
  button.setAttribute("image", icon);
  button.setAttribute("tooltiptext", tip);
  io.place(button, at);
  io.listen(button, "command", (ev: Event) => {
    // footer は click を止めるが、これは footer の中で起きるので自分で止める
    // (押した拍子にそのペインへ移ってしまうと、閉じたのがどれか分からなくなる)
    ev.stopPropagation();
    // タブは押されたときに引く。ペインの中身は入れ替わることがある
    const tab = tabOf(win, pane.id);
    if (tab) run(tab);
  });
}

/** その箱に紐づいているタブ。panel の id は、そのタブの linkedPanel */
function tabOf(win: ChromeWindow, panelId: string): XULTab | null {
  if (!panelId) return null;
  for (const tab of win.gBrowser.tabs) {
    if (tab.linkedPanel === panelId) return tab;
  }
  return null;
}

/** そのペインだけを見る。分割は解けて、ほかのタブは普通のタブとして残る */
function alone(win: ChromeWindow, tab: XULTab): void {
  const wrapper = tab.splitview;
  if (!wrapper) return;
  wrapper.unsplitTabs("nora_alone");
  win.gBrowser.selectedTab = tab;
}
splitview/io/grips.ts · 131 lines
// SPDX-License-Identifier: MPL-2.0

// ペインとペインの境。掴んで動かすと、動かしている最中に中身がついてくる。
//
// 本体の <splitter> は「DOM の前の兄弟の width 属性を書き換える」仕組みで、grid に
// 置き換えた並びとは噛み合わない(隣が誰かは line の番号が決めていて、兄弟の順では
// なくなっている)。だから境はこちらで持つ。やることは pointermove のたびに
// grid-template を書き換えるだけなので、追随は素直に起きる ── 離したときに初めて
// 形が変わる、にはならない。
//
// pointer は capture する。境を掴んだまま速く動かすと、pointer はすぐページの上に
// 出てしまい、その中は別のプロセスなので、capture していないと二度と戻ってこない。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow } from "../chrome.ts";
import type { Edges } from "../layout.ts";
import { MAX_PANES, grips as gripsOf, moveEdge } from "../layout.ts";
import type { PanelGrid } from "./panels.ts";
import { holdEdges } from "./prefs.ts";

export interface Grips {
  /** 形が変わった: 境を、いまの形のところへ */
  update(): void;
}

const ATTR_DRAGGING = "nora-split-dragging";

export function makeGrips(io: Io, win: ChromeWindow, grid: PanelGrid): Grips {
  const tabpanels = win.gBrowser.tabpanels;
  if (!tabpanels) throw new Error("[splitview] gBrowser.tabpanels が無い");

  // 境は作り直さず、使い回す。四枚を横に並べたときの三本が上限で、格子は二本
  // (縦と横)。作り直すと、掴んでいる最中に手の下から消えることがある。
  const bars: HTMLElement[] = [];
  for (let i = 0; i < MAX_PANES - 1; i++) {
    const bar = win.document.createXULElement("hbox") as unknown as HTMLElement;
    bar.className = "nora-split-grip";
    bar.hidden = true;
    io.place(bar, { parent: tabpanels });
    listen(io, win, grid, bar);
    bars.push(bar);
  }

  return {
    update(): void {
      const now = grid.now();
      const wanted = now ? gripsOf(now.layout, now.panes) : [];
      for (const [i, bar] of bars.entries()) {
        const want = wanted[i];
        if (!want) {
          bar.hidden = true;
          continue;
        }
        bar.hidden = false;
        bar.dataset.axis = want.axis;
        bar.dataset.index = String(want.index);
        bar.style.setProperty("grid-column", want.place.column);
        bar.style.setProperty("grid-row", want.place.row);
      }
    },
  };
}

function listen(io: Io, win: ChromeWindow, grid: PanelGrid, bar: HTMLElement): void {
  let held: Edges | null = null;

  io.listen(bar, "pointerdown", (ev: PointerEvent) => {
    if (ev.button !== 0) return;
    const now = grid.now();
    if (!now) return;
    ev.preventDefault();
    held = now.edges;
    // 掴んでいる間、ページは手を出さない。捕まえていても、ページの上を通ると
    // カーソルがそちらの言うなりになる(境を持っているのに I ビームになる)
    win.gBrowser.tabpanels?.setAttribute(ATTR_DRAGGING, "true");
    try {
      bar.setPointerCapture(ev.pointerId);
    } catch {
      // 捕まえられなくても動く(手がページの上に出たところで途切れるだけ)。
      // ここで止まって、掴んだことまで無かったことになるほうが困る
    }
  });

  io.listen(bar, "pointermove", (ev: PointerEvent) => {
    if (!held) return;
    const box = grid.box();
    if (!box) return;
    const axis = bar.dataset.axis === "row" ? "row" : "col";
    const index = Number(bar.dataset.index ?? 0);
    const to = axis === "col"
      ? (ev.clientX - box.left) / box.width
      : (ev.clientY - box.top) / box.height;
    held = axis === "col"
      ? { ...held, cols: moveEdge(held.cols, index, to) }
      : { ...held, rows: moveEdge(held.rows, index, to) };
    grid.slide(held);
  });

  const done = (ev: PointerEvent): void => {
    if (!held) return;
    if (bar.hasPointerCapture(ev.pointerId)) bar.releasePointerCapture(ev.pointerId);
    win.gBrowser.tabpanels?.removeAttribute(ATTR_DRAGGING);
    holdEdges(held);
    held = null;
  };
  io.listen(bar, "pointerup", done);
  io.listen(bar, "pointercancel", done);

  // キーでも動かせる。境は tabindex を持っていて、Tab で辿り着ける
  io.listen(bar, "keydown", (ev: KeyboardEvent) => {
    const now = grid.now();
    if (!now) return;
    const axis = bar.dataset.axis === "row" ? "row" : "col";
    const index = Number(bar.dataset.index ?? 0);
    const step = ev.key === "ArrowLeft" || ev.key === "ArrowUp"
      ? -0.02
      : ev.key === "ArrowRight" || ev.key === "ArrowDown"
      ? 0.02
      : 0;
    if (step === 0) return;
    ev.preventDefault();
    const from = axis === "col" ? now.edges.cols : now.edges.rows;
    const to = (from[index] ?? 0.5) + step;
    const next = axis === "col"
      ? { ...now.edges, cols: moveEdge(now.edges.cols, index, to) }
      : { ...now.edges, rows: moveEdge(now.edges.rows, index, to) };
    grid.slide(next);
    holdEdges(next);
  });
}
splitview/io/groups.ts · 94 lines
// SPDX-License-Identifier: MPL-2.0

// タブグループとの、二つの繋がり。
//
// 本体は「分割ビューをグループに入れる」をもう持っている(タブの右クリックの
// "Move split view to new group"、gBrowser.moveSplitViewToExistingGroup)。
// 無いのは**逆**で、すでにあるグループを、そのまま分割ビューにする口。
// 開いた四つのタブを並べて見たい、はグループがいちばん近いところに居るので、
// グループを編むあのパネルに一行足す。
//
// もう一つは見た目のほう。分割ビューがグループの中にあるなら、境と縁を、その
// グループの色にする ── どのグループを見ているのかが、タブの列を見上げなくても
// 分かる。色は tab-group 要素に --tab-group-color として載っているので、
// 解決済みの値を写すだけ。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow, TabGroup, TabGroupEditor } from "../chrome.ts";
import { MAX_PANES } from "../layout.ts";

export interface Groups {
  /** 形が変わった: いま見ている分割ビューのグループの色を、写す */
  update(): void;
}

const OPEN = "Open in split view";
const TINT = "--nora-split-tint";

export function makeGroups(io: Io, win: ChromeWindow): Groups {
  addEditorRow(io, win);

  const tabpanels = win.gBrowser.tabpanels;
  io.defer(() => tabpanels?.style.removeProperty(TINT));

  return {
    update(): void {
      if (!tabpanels) return;
      const group = win.gBrowser.selectedTab?.splitview?.group ?? null;
      const tint = group ? win.getComputedStyle(group).getPropertyValue("--tab-group-color") : "";
      if (tint) tabpanels.style.setProperty(TINT, tint);
      else tabpanels.style.removeProperty(TINT);
    },
  };
}

/** グループを編むパネルの、"Ungroup tabs" の手前に一行 */
function addEditorRow(io: Io, win: ChromeWindow): void {
  const doc = win.document;
  const editor = doc.getElementById("tab-group-editor") as TabGroupEditor | null;
  if (!editor) return;
  const before = doc.getElementById("tabGroupEditor_ungroupTabs");
  const row = doc.createXULElement("toolbarbutton");
  row.id = "nora-splitview-group";
  row.className = "subviewbutton";
  row.setAttribute("tabindex", "0");
  row.setAttribute("label", OPEN);
  row.setAttribute("hidden", "true");
  io.place(row, before && before.parentNode ? { before } : { parent: editor });

  io.listen(row, "command", () => {
    const group = editor.activeGroup;
    editor.querySelector("panel")?.hidePopup();
    if (group) split(win, group);
  });

  // パネルは名前を作るときにも編むときにも開く。行が意味を持つのは、そのグループに
  // まだ分割になっていないタブが二枚以上あるときだけ
  io.listen(editor, "popupshowing", () => {
    const ready = editor.activeGroup ? free(editor.activeGroup).length >= 2 : false;
    if (ready) row.removeAttribute("hidden");
    else row.setAttribute("hidden", "true");
  });
}

/** そのグループの、まだ分割に入っていないタブたち */
function free(group: TabGroup) {
  return group.tabs.filter((tab) => !tab.pinned && !tab.splitview);
}

/**
 * グループのタブを分割ビューに。四枚を超えるグループなら、先頭の四枚まで
 * (残りはグループの中にそのまま居る ── 勝手に閉じたり、外に出したりはしない)。
 *
 * 束は tabContainer の直下に作られるので、作ってからグループへ戻す。本体の
 * moveSplitViewToExistingGroup が、そのときの後始末まで見てくれる。
 */
function split(win: ChromeWindow, group: TabGroup): void {
  const tabs = free(group).slice(0, MAX_PANES);
  if (tabs.length < 2) return;
  const wrapper = win.gBrowser.addTabSplitView(tabs);
  if (!wrapper) return;
  win.gBrowser.moveSplitViewToExistingGroup(wrapper, group);
  win.gBrowser.selectedTab = tabs[0];
}
splitview/io/menu.ts · 99 lines
// SPDX-License-Identifier: MPL-2.0

// タブの右クリックに二行。
//
// 本体にも "Open in split view" はあるが、それは**新しい分割ビューを作る**行で、
// もう分割ビューに入っているタブでは消える(tab-context-menu.js: hasSplitViewTab)。
// 二枚が上限だったのだから、それで足りていた。三枚目・四枚目を足す口はここで開ける。
//
// 出る行は、そのとき意味のあるものだけ ── 何も起きない行が並んでいるほうが、
// 行が無いより分かりにくい(rename-tab の "Clear name" と同じ約束)。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow, XULTab } from "../chrome.ts";
import { MAX_PANES } from "../layout.ts";

const ADD = "Add to split view";
const DROP = "Remove this pane";

export function addMenuRows(io: Io, win: ChromeWindow): void {
  const doc = win.document;
  const popup = doc.getElementById("tabContextMenu");
  if (!popup) return;
  // **末尾に置く。** 本体の分割ビューの行の隣に並べたいところだが、
  // MenuSectionLayout は「どの section も名指ししていない子」を見つけると投げる。
  // 例外は**末尾に足された一続き**だけ(open な section を持つ popup)。途中に
  // 挟むと、メニューを開くたびにエラーが console に出る ── そしてそれが溜まると、
  // console 自身の記録が再帰する(io/panels.ts の空 id の話と同じ道)
  const at = { parent: popup };

  const row = (id: string, label: string, run: (tab: XULTab) => void): Element => {
    const item = doc.createXULElement("menuitem");
    item.id = id;
    item.setAttribute("label", label);
    item.setAttribute("hidden", "true");
    io.place(item, at);
    io.listen(item, "command", () => {
      const tab = win.TabContextMenu?.contextTab;
      if (tab) run(tab);
    });
    return item;
  };

  const add = row("nora-splitview-add", ADD, (tab) => addToSplit(win, tab));
  const drop = row("nora-splitview-drop", DROP, (tab) => dropFromSplit(win, tab));

  io.listen(popup, "popupshowing", () => {
    const tab = win.TabContextMenu?.contextTab;
    show(add, !!tab && canAdd(win, tab));
    show(drop, !!tab?.splitview && tab.splitview.tabs.length > 2);
  });
}

function show(item: Element, yes: boolean): void {
  // 属性のほう。XUL で行が隠れるのはこちら
  if (yes) item.removeAttribute("hidden");
  else item.setAttribute("hidden", "true");
}

/** そのタブを足せるか。もう入っている / いっぱい / いま見ているタブ自身、では足せない */
export function canAdd(win: ChromeWindow, tab: XULTab): boolean {
  if (tab.pinned) return false;
  const active = win.gBrowser.selectedTab?.splitview;
  if (active) return tab.splitview !== active && active.tabs.length < MAX_PANES;
  return tab !== win.gBrowser.selectedTab && !tab.splitview;
}

/**
 * いま分割ビューを見ているなら、そこへ一枚足す。まだなら、いま見ているタブと
 * 二枚で始める(本体の "Open in split view" と同じ入口)。
 *
 * 本体の行は browser.tabs.splitView.enabled が false だと出ないが、この行は出す。
 * 分割ビューを二枚から四枚に開ける drop を、わざわざ入れた人が居るところなので
 * ── その pref のほうを、drop が黙って書き換えることはしない(設定は入れた人のもの)。
 */
export function addToSplit(win: ChromeWindow, tab: XULTab): void {
  const active = win.gBrowser.selectedTab?.splitview;
  if (active) {
    if (active.tabs.length >= MAX_PANES) return;
    active.addTabs([tab]);
    return;
  }
  const current = win.gBrowser.selectedTab;
  if (!current || current === tab) return;
  win.gBrowser.addTabSplitView([current, tab]);
}

/**
 * 一枚だけ、分割の外へ。タブは閉じない ── 列の、その束のすぐ後ろに戻す。
 * 本体の "Separate" は束ごと解くので、三枚のうち一枚だけ、ができない。
 * (二枚から一枚を抜くと本体が自分で束を解くので、そのときはこの行を出さない)
 */
function dropFromSplit(win: ChromeWindow, tab: XULTab): void {
  const wrapper = tab.splitview;
  if (!wrapper) return;
  win.gBrowser.handleTabMove(tab, () => {
    win.gBrowser.tabContainer.insertBefore(tab, wrapper.nextElementSibling);
  });
}
splitview/io/panels.ts · 176 lines
// SPDX-License-Identifier: MPL-2.0

// 芯。#tabbrowser-tabpanels の中を、flex から grid にする。
//
// 本体は分割ビューのペインを flex で横に並べ、CSS が column="0" と column="1" の
// order だけを知っている。三枚目は行き場が無く、縦にも格子にもならない。
// grid にすると置き場所を一枚ずつ line の番号で言えるので、order も DOM の順も
// 要らなくなる。
//
// 掴むところは一つだけ ── MozTabpanels の `setSplitViewActive`。本体は分割ビューが
// 動くたび(タブを足す、選び替える、別のタブへ移る、外す)ここを必ず通るので、
// ここの後ろに並べ直しを足すだけで、ほかの道を塞がずに済む。包むのは prototype
// ではなく **その窓の tabpanels ひとつ**なので、外せば delete 一つで元に戻る。
//
// 本体の splitter は隠す(grid では兄弟の幅を動かす仕組みが噛み合わない)。
// 代わりの境は io/grips.ts が置く。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow, Tabpanels } from "../chrome.ts";
import type { Edges, Layout } from "../layout.ts";
import { effective, fitEdges, paneAt, template } from "../layout.ts";
import { heldEdges, heldLayout } from "./prefs.ts";

/** いま窓に出ている分割ビューの形 */
export interface Grid {
  layout: Layout;
  panes: number;
  edges: Edges;
}

export interface PanelGrid {
  /** いまの形。分割ビューが出ていなければ null */
  now(): Grid | null;
  /** pref を読み直して並べ直す */
  arrange(): void;
  /** ドラッグの最中: 境だけ書き換える(pref には触らない) */
  slide(edges: Edges): void;
  /** ペインの箱たち(左上から) */
  panes(): HTMLElement[];
  /** 格子の外枠。境を掴んだ位置を割合に直すのに使う */
  box(): DOMRect | null;
}

const ATTR_LAYOUT = "nora-split";
const ATTR_PANES = "nora-split-panes";
/**
 * タブを掴んでいる間、分割ビューを出したままにしている印(io/tabdrop.ts)。
 * そのとき選ばれているのは掴んだタブ ── 分割の外のタブ ── なので、
 * 「いま分割ビューを見ているか」を選択だけで決めると、格子が解けてしまう。
 */
export const ATTR_HELD = "nora-split-held";

/**
 * 掴んでいる間、そのまま置いておく panel(分割ビューを見ていなかったとき)。
 * 印は panel のほうに付く ── 見せたいのは、選択が移ったあとの「前の一枚」なので。
 */
export const ATTR_PEEK = "nora-split-peek";

export function makeGrid(io: Io, win: ChromeWindow, onChange: (grid: Grid | null) => void): PanelGrid {
  const tabpanels = win.gBrowser.tabpanels;
  if (!tabpanels) throw new Error("[splitview] gBrowser.tabpanels が無い");

  let held: Grid | null = null;
  // この drop が置き場所を書いた箱たち。分割ビューから外れた panel は
  // splitViewPanels から消えるので、そのとき誰も掃除しなくなる ── 覚えておく。
  const touched = new Set<HTMLElement>();

  const panes = (): HTMLElement[] => {
    const out: HTMLElement[] = [];
    for (const id of tabpanels.splitViewPanels) {
      // まだ browser の挿さっていないタブの linkedPanel は空文字。getElementById("")
      // は一回ごとに警告を console へ出すので、渡さない(数が出ると、console 自身の
      // 記録が例外を呼んで再帰する ── ConsoleAPIStorage.recordEvent)
      if (!id) continue;
      const el = win.document.getElementById(id);
      if (el) out.push(el);
    }
    return out;
  };

  let arranging = false;
  const arrange = (): void => {
    // 並べ直しの途中で、並べ直しが呼ばれることがある(属性や style を変えると
    // 本体の setSplitViewActive が走り直す道がある)。二枚目は要らない
    if (arranging) return;
    arranging = true;
    try {
      arrangeOnce();
    } finally {
      arranging = false;
    }
  };

  const arrangeOnce = (): void => {
    const found = panes();
    // 分割ビューを見ていないとき(別のタブに移った、二枚に満たない)は手を引く。
    // 本体の flex がそのまま効く形に戻しておく ── 掛けっぱなしの grid で、
    // 見ていない間に何かが起きるほうが怖い。
    const showing = tabpanels.hasAttribute("splitview") &&
      (!!win.gBrowser.selectedTab?.splitview || tabpanels.hasAttribute(ATTR_HELD));
    if (!showing || found.length < 2) {
      strip();
      held = null;
      onChange(null);
      return;
    }

    const layout = effective(heldLayout(), found.length);
    const edges = fitEdges(layout, found.length, heldEdges());
    held = { layout, panes: found.length, edges };

    tabpanels.setAttribute(ATTR_LAYOUT, layout);
    tabpanels.setAttribute(ATTR_PANES, String(found.length));
    slide(edges);
    for (const pane of touched) {
      if (!found.includes(pane)) unplace(pane);
    }
    for (const [i, pane] of found.entries()) {
      const place = paneAt(layout, found.length, i);
      pane.style.setProperty("grid-column", place.column);
      pane.style.setProperty("grid-row", place.row);
      touched.add(pane);
    }
    onChange(held);
  };

  const slide = (edges: Edges): void => {
    if (!held) return;
    held = { ...held, edges };
    const tracks = template(held.layout, held.panes, edges);
    tabpanels.style.setProperty("grid-template-columns", tracks.columns);
    tabpanels.style.setProperty("grid-template-rows", tracks.rows);
  };

  // 本体が分割ビューを動かしたあとに、並べ直す。元の仕事は先に済ませてもらう
  // (splitter の hidden、DOM の順、selectedPanel の付け直しが、そこで起きる)。
  const proto = Object.getPrototypeOf(tabpanels) as Tabpanels;
  const original = proto.setSplitViewActive;
  Object.defineProperty(tabpanels, "setSplitViewActive", {
    configurable: true,
    writable: true,
    value: function (this: Tabpanels, active: boolean): void {
      original.call(this, active);
      arrange();
    },
  });
  io.defer(() => {
    delete (tabpanels as unknown as Record<string, unknown>).setSplitViewActive;
    strip();
  });

  /** 置いたものを、置く前の姿へ */
  function strip(): void {
    tabpanels.removeAttribute(ATTR_LAYOUT);
    tabpanels.removeAttribute(ATTR_PANES);
    tabpanels.removeAttribute(ATTR_HELD);
    tabpanels.style.removeProperty("grid-template-columns");
    tabpanels.style.removeProperty("grid-template-rows");
    for (const pane of touched) unplace(pane);
    touched.clear();
  }

  return {
    now: () => held,
    arrange,
    slide,
    panes,
    box: () => tabpanels.getBoundingClientRect(),
  };
}

function unplace(pane: HTMLElement): void {
  pane.style.removeProperty("grid-column");
  pane.style.removeProperty("grid-row");
}
splitview/io/picker.ts · 111 lines
// SPDX-License-Identifier: MPL-2.0

// 並べかたを、字ではなく絵で選ぶ ── Windows のスナップレイアウトの、あの格子。
// 「横に並べる / 縦に並べる / 格子」の三つが、いまの枚数で実際どう見えるかを
// そのまま小さく描く(三枚の格子は、左が一枚と右が上下二枚)。
//
// 置き場所は本体の #split-view-menu。ペインの隅の「…」と、URL バーの分割ビューの
// アイコンが、どちらもここを開く ── 並べかたを変えたくなる場所は、もうそこにある。
//
// 絵の中の位置は、本物の grid とは別に数える。本物には境の track が挟まって
// いるが、絵にはそれが無い(gap で足りる)ので、番号がひとつずつずれる。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow } from "../chrome.ts";
import type { Layout } from "../layout.ts";
import { LAYOUTS, effective } from "../layout.ts";
import type { PanelGrid } from "./panels.ts";
import { heldLayout, holdLayout } from "./prefs.ts";

export interface Picker {
  update(): void;
}

const NAMES: Record<Layout, string> = {
  columns: "Side by side",
  rows: "Stacked",
  grid: "Grid",
};

export function makePicker(io: Io, win: ChromeWindow, grid: PanelGrid): Picker {
  const doc = win.document;
  const menu = doc.getElementById("split-view-menu");
  if (!menu) return { update: () => {} };

  const strip = doc.createXULElement("hbox");
  strip.className = "nora-split-picker";
  const shapes: HTMLElement[] = [];
  for (const layout of LAYOUTS) {
    const button = doc.createXULElement("toolbarbutton") as unknown as HTMLElement;
    button.className = "nora-split-shape";
    button.dataset.layout = layout;
    button.setAttribute("tooltiptext", NAMES[layout]);
    const art = doc.createXULElement("box");
    art.className = "nora-split-shape-art";
    button.appendChild(art);
    strip.appendChild(button);
    shapes.push(button);
    io.listen(button, "command", () => {
      holdLayout(layout);
      update();
    });
  }
  const line = doc.createXULElement("menuseparator");
  line.className = "nora-split-picker-line";

  // メニューのいちばん上。「解く」「入れ替える」より先に、まず形の話
  const first = menu.firstElementChild;
  const at = first ? { before: first } : { parent: menu };
  io.place(strip, at);
  io.place(line, at);

  const update = (): void => {
    const now = grid.now();
    const panes = now?.panes ?? 2;
    const chosen = heldLayout();
    for (const button of shapes) {
      const layout = (button.dataset.layout ?? "columns") as Layout;
      // 二枚のときの「格子」は横並びと同じもの。選べる顔をしていないほうが正直
      const same = effective(layout, panes) === layout;
      button.hidden = !same;
      if (chosen === layout) button.setAttribute("selected", "true");
      else button.removeAttribute("selected");
      draw(win, button.firstElementChild as HTMLElement, layout, panes);
    }
  };
  io.listen(menu, "popupshowing", () => update());
  update();
  return { update };
}

/** その並べかたが、いまの枚数でどう見えるか。小さな箱の並び */
function draw(win: ChromeWindow, art: HTMLElement, layout: Layout, panes: number): void {
  const shape = effective(layout, panes);
  art.textContent = "";
  art.style.setProperty("grid-template-columns", shape === "columns" ? cells(panes) : shape === "rows" ? "1fr" : "1fr 1fr");
  art.style.setProperty("grid-template-rows", shape === "rows" ? cells(panes) : shape === "columns" ? "1fr" : "1fr 1fr");
  for (let i = 0; i < panes; i++) {
    const cell = win.document.createXULElement("box") as unknown as HTMLElement;
    cell.className = "nora-split-shape-cell";
    const place = spot(shape, panes, i);
    cell.style.setProperty("grid-column", place.column);
    cell.style.setProperty("grid-row", place.row);
    art.appendChild(cell);
  }
}

function cells(panes: number): string {
  return `repeat(${panes}, 1fr)`;
}

/** 絵の中の i 枚目。境の track が無いぶん、本物より番号が詰まっている */
function spot(shape: Layout, panes: number, i: number): { column: string; row: string } {
  if (shape === "columns") return { column: String(i + 1), row: "1 / -1" };
  if (shape === "rows") return { column: "1 / -1", row: String(i + 1) };
  if (panes <= 3) {
    if (i === 0) return { column: "1", row: "1 / -1" };
    return { column: "2", row: String(i) };
  }
  return { column: String((i % 2) + 1), row: String(Math.floor(i / 2) + 1) };
}
splitview/io/prefs.ts · 54 lines
// SPDX-License-Identifier: MPL-2.0

// 覚えておくこと三つ。設定は about:config の pref 一本ずつで(docs/LAYERS.md)。
//
// 境は「0.5」「0.33,0.66」のようなカンマ区切りの字。JSON でないのは、これが
// 目で読めて手で直せるものでいてほしいから ── about:config で「0.5」と見えて、
// 「0.3」と書き換えれば、その場で境が動く。

import type { Edges, Layout } from "../layout.ts";
import { LAYOUTS } from "../layout.ts";

export const PREF_LAYOUT = "noraneko.splitview.layout";
export const PREF_COLS = "noraneko.splitview.cols";
export const PREF_ROWS = "noraneko.splitview.rows";

export const PREFS = [PREF_LAYOUT, PREF_COLS, PREF_ROWS];

export function heldLayout(): Layout {
  const held = Services.prefs.getStringPref(PREF_LAYOUT, "columns");
  return (LAYOUTS as string[]).includes(held) ? (held as Layout) : "columns";
}

export function holdLayout(layout: Layout): void {
  Services.prefs.setStringPref(PREF_LAYOUT, layout);
}

export function heldEdges(): Edges {
  return {
    cols: numbers(Services.prefs.getStringPref(PREF_COLS, "")),
    rows: numbers(Services.prefs.getStringPref(PREF_ROWS, "")),
  };
}

export function holdEdges(edges: Edges): void {
  Services.prefs.setStringPref(PREF_COLS, text(edges.cols));
  Services.prefs.setStringPref(PREF_ROWS, text(edges.rows));
}

/** 読めない字は無かったことに ── 手で書き換えられる場所なので、転ばないほうを選ぶ */
function numbers(held: string): number[] {
  if (held === "") return [];
  const out: number[] = [];
  for (const part of held.split(",")) {
    const value = Number.parseFloat(part);
    if (!Number.isFinite(value) || value <= 0 || value >= 1) return [];
    out.push(value);
  }
  return out;
}

function text(edges: number[]): string {
  return edges.map((edge) => edge.toFixed(4)).join(",");
}
splitview/io/tabdrop.ts · 372 lines
// SPDX-License-Identifier: MPL-2.0

// タブを掴んで落とす。二つの落とし先がある。
//
// **タブの列の束の上** ── その分割ビューに足される。
//
// **ページの上**(Vivaldi のあれ) ── 真ん中なら新しい窓、端なら、その向きで並ぶ。
//
// 二つ目には、先に気づいておくことがある: **タブは掴んだ瞬間に選ばれる**。
// だから落とす先のページはもう掴んだタブ自身になっていて、分割ビューのほうは
// 「分割の外のタブが選ばれた」として畳まれている(tabsplitview.js の #suspend)。
// つまり「いま見ているものの隣に置く」とは書けない ── 見ているのは、掴んだ
// タブなので。
//
// なので**掴む前に見ていたもの**を覚えておく(TabSelect の previousTab)。
// 相手はそれ。掴んだタブが前に出るのは、むしろ落とし先の絵を大きく描ける
// ということでもあるので、どこに入るかは矩形で先に出す。

import type { Io } from "../../_shared/io.ts";
import type { ChromeWindow, SplitViewWrapper, XULTab } from "../chrome.ts";
import { MAX_PANES } from "../layout.ts";
import { ATTR_HELD, ATTR_PEEK, type PanelGrid } from "./panels.ts";
import { holdLayout } from "./prefs.ts";

const TAB_FLAVOR = "application/x-moz-tabbrowser-tab";
const ATTR_TARGET = "nora-split-target";
/** 真ん中の、この割合が「新しい窓」。外側は、いちばん近い端 */
const MIDDLE = 0.4;

type Zone = "window" | "start" | "end" | "top" | "bottom";

interface TabTransfer extends DataTransfer {
  mozGetDataAt(flavor: string, index: number): unknown;
}

export function makeTabDrop(io: Io, win: ChromeWindow, grid: PanelGrid): void {
  // 掴む前に見ていたタブ。掴んだ時点で選択はもう移っているので、こちらで覚える
  let before: XULTab | null = null;
  io.listen(win.gBrowser.tabContainer, "TabSelect", (ev: CustomEvent) => {
    const prev = ev.detail?.previousTab as XULTab | undefined;
    if (prev?.parentNode) before = prev;
  });

  const grip = hold(io, win, grid, () => before);
  stripDrop(io, win);
  // 相手は hold が押した時点で覚えたもの。ここで別に決めると、二つがずれた
  // ときに「相手が居ない」= 新しい窓、に落ちる
  pageDrop(io, win, grip.partner);
  // 後始末は、落とす処理より**後ろ**に。どちらも capture で受けていて、先に走ると
  // 「まだ分割に入っていない」と見て一度畳んでしまう(落とした瞬間にちらつく)
  io.listen(win.gBrowser.tabpanels!, "drop", grip.stop, true);
  io.listen(win.gBrowser.tabContainer, "drop", grip.stop, true);
}

// --- 掴んでいる間、分割ビューを出したままにする -------------------------------

/**
 * Vivaldi 式が Vivaldi 式であるためには、これが要る。
 *
 * タブを掴むとそのタブが選ばれ、本体は「分割の外のタブへ移った」として分割ビューを
 * 畳む(tabsplitview.js の #suspend)。すると画面に出ているのは掴んだタブのページ
 * だけで、その上に「ここに入る」と描いても、下に何も無いのだから絵が嘘になる。
 * **落とす先が見えていないと、選べない。**
 *
 * 塞ぐのは `dragstart` ではなく `mousedown`。dragstart は数ピクセル動かしてから
 * 来るので、そこで出し直しても「一度畳んでから戻す」ことにしかならず、切り替わりが
 * そのまま見えてしまう。畳まれるのは押した瞬間の TabSelect なので、それより前に
 * 立つ必要がある。
 *
 * そして畳む口そのもの ── tabpanels.suspendSplitViewPanels ── を、その間だけ
 * 塞ぐ。出し直すのではなく、はじめから畳ませない。
 *
 * 押しただけ(掴まなかった)なら、離した時点で本来の姿へ。掴んだなら、落とすまで
 * 出たまま ── そのタブが分割に入れば本体がそのまま続け、入らなかったなら、本来
 * そうなるはずだった畳んだ姿に戻す。
 *
 * 分割ビューを見ていないときも、同じ話が要る。about:support を見ていて
 * about:robots を掴んだら、掴んでいる間そこに居てほしいのは support のほう ──
 * そうでないと「右に落としたら二枚になる」の右と左が、落とすまで分からない。
 * 畳む口は関係ないので、こちらは見ていた panel に印をつけて、CSS でそのまま
 * 置いておく(掴んだタブのページは、どちらの場合も隠す)。
 */
function hold(
  io: Io,
  win: ChromeWindow,
  grid: PanelGrid,
  before: () => XULTab | null,
): { stop: () => void; partner: () => XULTab | null } {
  const tabpanels = win.gBrowser.tabpanels;
  if (!tabpanels) return { stop: () => {}, partner: () => null };
  let armed: SplitViewWrapper | null = null;
  /** 押した時点で見ていたタブ。落としたときの相手は、これ */
  let looking: XULTab | null = null;
  /** 分割ビューを見ていなかったとき、掴む前に見ていたページの箱 */
  let peeked: Element | null = null;
  let dragging = false;

  // 畳む口は一つだけ。包むのは prototype ではなく、その窓の tabpanels ひとつなので、
  // 外せば delete 一つで戻る(io/panels.ts の setSplitViewActive と同じ手)
  const proto = Object.getPrototypeOf(tabpanels) as typeof tabpanels;
  const original = proto.suspendSplitViewPanels;
  Object.defineProperty(tabpanels, "suspendSplitViewPanels", {
    configurable: true,
    writable: true,
    value: function (this: typeof tabpanels, tabs: XULTab[]): void {
      if (armed) return;
      original.call(this, tabs);
    },
  });
  io.defer(() => {
    delete (tabpanels as unknown as Record<string, unknown>).suspendSplitViewPanels;
  });

  const release = (): void => {
    if (!armed && !peeked) return;
    const wrapper = armed;
    armed = null;
    dragging = false;
    peeked?.removeAttribute(ATTR_PEEK);
    peeked = null;
    looking = null;
    tabpanels.removeAttribute(ATTR_HELD);
    // 掴んだタブが分割に入ったなら、本体がもう続きを持っている。入らなかったなら、
    // 押した時点で起きるはずだったことを、ここで起こす
    if (wrapper && !win.gBrowser.selectedTab?.splitview && wrapper.parentNode) {
      tabpanels.suspendSplitViewPanels(wrapper.tabs);
    }
    grid.arrange();
  };
  io.defer(release);

  io.listen(win.gBrowser.tabContainer, "mousedown", (ev: MouseEvent) => {
    // 前のが残っていたら、まずここで戻す。何かの拍子に離したことが伝わらなかった
    // とき(窓の外で離した、drag session が途中で終わった)、次にタブを押せば
    // 元に戻る ── 画面が切り替わらないまま、というのがいちばん困る
    release();
    if (ev.button !== 0) return;
    const over = ev.target as Element | null;
    const pressed = over?.closest?.(".tabbrowser-tab") as XULTab | null;
    if (!pressed) return;
    const showing = win.gBrowser.selectedTab;
    if (!showing) return;
    // いま見ているものを掴んだ: 選択も画面も動かないので、守るものが無い
    if (pressed === showing) return;
    // 同じ分割の中の一枚を掴んだ: 本体はそもそも畳まない
    if (pressed.splitview && pressed.splitview === showing.splitview) return;
    looking = showing;
    const wrapper = showing.splitview ?? null;
    if (wrapper?.parentNode && wrapper.tabs.length) {
      // 分割ビューを見ている: 畳ませない
      armed = wrapper;
    } else {
      // ふつうのタブを見ている: その一枚を、掴んでいる間そのまま置いておく。
      // まだ browser の挿さっていないタブには箱が無い(linkedPanel が空文字)
      if (!showing.linkedPanel) {
        looking = null;
        return;
      }
      const panel = win.document.getElementById(showing.linkedPanel);
      if (!panel) {
        looking = null;
        return;
      }
      panel.setAttribute(ATTR_PEEK, "true");
      peeked = panel;
    }
    tabpanels.setAttribute(ATTR_HELD, "true");
  }, true);

  // 掴んだ ── ここから先、離すのは dragend か drop
  io.listen(win.gBrowser.tabContainer, "dragstart", () => {
    if (armed || peeked) dragging = true;
  }, true);

  // 押しただけだった。document で聞くのは、窓のどこで離してもいいように
  io.listen(win.document, "mouseup", () => {
    if (!dragging) release();
  }, true);

  const stop = (): void => {
    dragging = false;
    release();
  };
  io.listen(win.gBrowser.tabContainer, "dragend", stop, true);
  // 窓から焦点が外れたまま離された、も同じところへ帰る
  io.listen(win, "blur", stop);
  return { stop, partner: () => looking ?? before() };
}

// --- タブの列の束の上に落とす -------------------------------------------------

function stripDrop(io: Io, win: ChromeWindow): void {
  const strip = win.gBrowser.tabContainer;
  let lit: Element | null = null;
  const light = (wrapper: Element | null): void => {
    if (lit === wrapper) return;
    lit?.removeAttribute(ATTR_TARGET);
    wrapper?.setAttribute(ATTR_TARGET, "true");
    lit = wrapper;
  };
  io.defer(() => light(null));

  // capture で受ける。本体のタブの並べ替えが先に手を出す前に、束の上かどうかを
  // 見て、そこだけ引き取る
  io.listen(strip, "dragover", (ev: DragEvent) => {
    const found = onWrapper(win, ev);
    light(found?.wrapper ?? null);
    if (!found) return;
    ev.preventDefault();
    ev.stopPropagation();
    if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
  }, true);

  io.listen(strip, "drop", (ev: DragEvent) => {
    const found = onWrapper(win, ev);
    light(null);
    if (!found) return;
    ev.preventDefault();
    ev.stopPropagation();
    found.wrapper.addTabs([found.tab]);
    win.gBrowser.selectedTab = found.tab;
  }, true);

  const done = (): void => light(null);
  io.listen(strip, "dragleave", done, true);
  io.listen(strip, "dragend", done, true);
}

/** 落とせる束と、落とされているタブ。どちらか欠けたら null */
function onWrapper(win: ChromeWindow, ev: DragEvent): { wrapper: SplitViewWrapper; tab: XULTab } | null {
  const tab = dragged(win, ev);
  if (!tab) return null;
  const over = ev.target as Element | null;
  const wrapper = over?.closest?.("tab-split-view-wrapper") as SplitViewWrapper | null;
  if (!wrapper || wrapper.tabs.length >= MAX_PANES) return null;
  if (tab.splitview === wrapper) return null;
  return { wrapper, tab };
}

// --- ページの上に落とす(Vivaldi 式) -----------------------------------------

function pageDrop(io: Io, win: ChromeWindow, before: () => XULTab | null): void {
  const tabpanels = win.gBrowser.tabpanels;
  if (!tabpanels) return;

  // どこに入るかの矩形。ページの上に浮くので、自分は当たり判定を持たない
  const hint = win.document.createXULElement("box") as unknown as HTMLElement;
  hint.className = "nora-split-hint";
  hint.hidden = true;
  io.place(hint, { parent: tabpanels });
  const hide = (): void => {
    hint.hidden = true;
  };

  io.listen(tabpanels, "dragover", (ev: DragEvent) => {
    const tab = dragged(win, ev);
    if (!tab) return;
    const partner = mate(win, before(), tab);
    // 相手が居なければ、並べようがない。それでも新しい窓にはできる
    ev.preventDefault();
    // ここから先へは渡さない。ページの上の drop は本体にも受け手が居て、
    // 渡すと「その URL を開く」が二重に走る(空のタブが増える)
    ev.stopPropagation();
    if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
    const box = tabpanels.getBoundingClientRect();
    const zone = zoneOf(box, ev.clientX, ev.clientY, !!partner);
    show(hint, zone, partner ? panes(partner) + 1 : 1);
  }, true);

  io.listen(tabpanels, "drop", (ev: DragEvent) => {
    const tab = dragged(win, ev);
    hide();
    if (!tab) return;
    ev.preventDefault();
    // stopImmediatePropagation ではない ── 同じ tabpanels の capture には、この
    // 後ろに掴みの後始末が並んでいて、それまで止めてしまう
    ev.stopPropagation();
    // 「move として受け取った」と言い切る。本体の dragend はここを見て、
    // タブを新しい窓へ引きちぎる道に入るかどうかを決める
    if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
    const partner = mate(win, before(), tab);
    const box = tabpanels.getBoundingClientRect();
    const zone = zoneOf(box, ev.clientX, ev.clientY, !!partner);
    if (zone === "window" || !partner) {
      win.gBrowser.replaceTabWithWindow(tab);
      return;
    }
    holdLayout(zone === "top" || zone === "bottom" ? "rows" : "columns");
    join(win, partner, tab, zone === "start" || zone === "top");
  }, true);

  io.listen(tabpanels, "dragleave", (ev: DragEvent) => {
    // 中の要素へ移っただけの dragleave は無視する(出たり入ったりで点滅する)
    const to = ev.relatedTarget as Node | null;
    if (to && tabpanels.contains(to)) return;
    hide();
  }, true);
  io.listen(tabpanels, "dragend", hide, true);
}

/** 掴む前に見ていたもの。それが分割ビューなら束ごと、ふつうのタブならそのタブ */
function mate(win: ChromeWindow, before: XULTab | null, tab: XULTab): SplitViewWrapper | XULTab | null {
  const found = before ?? win.gBrowser.selectedTab;
  if (!found || found === tab || !found.parentNode) return null;
  const wrapper = found.splitview;
  if (wrapper) return wrapper.tabs.length < MAX_PANES && tab.splitview !== wrapper ? wrapper : null;
  return found.pinned ? null : found;
}

function panes(partner: SplitViewWrapper | XULTab): number {
  return "tabs" in partner ? partner.tabs.length : 1;
}

/** 相手と並べる。束があればそこへ足し、無ければ二枚で始める */
function join(win: ChromeWindow, partner: SplitViewWrapper | XULTab, tab: XULTab, first: boolean): void {
  if ("tabs" in partner) {
    // 束への差し込みは末尾。先頭に入れる道は本体に無い(addTabs は push)
    partner.addTabs([tab]);
  } else {
    win.gBrowser.addTabSplitView(first ? [tab, partner] : [partner, tab]);
  }
  win.gBrowser.selectedTab = tab;
}

/** 手がどこに居るか。真ん中は新しい窓、外側はいちばん近い端 */
function zoneOf(box: DOMRect, x: number, y: number, canSplit: boolean): Zone {
  const u = (x - box.left) / box.width;
  const v = (y - box.top) / box.height;
  const edge = Math.min(u, 1 - u, v, 1 - v);
  if (!canSplit || edge > MIDDLE / 2) return "window";
  if (edge === u) return "start";
  if (edge === 1 - u) return "end";
  return edge === v ? "top" : "bottom";
}

/** 矩形を、落としたらそこに座る場所へ。新しい窓のときは、浮いた小さな窓の絵 */
function show(hint: HTMLElement, zone: Zone, panes: number): void {
  const share = `${(100 / Math.max(panes, 2)).toFixed(2)}%`;
  const set = (inline: string, block: string, w: string, h: string): void => {
    hint.style.setProperty("inset-inline", inline);
    hint.style.setProperty("inset-block", block);
    hint.style.setProperty("width", w);
    hint.style.setProperty("height", h);
  };
  if (zone === "window") set("0", "0", "62%", "62%"); // 中央寄せは margin: auto(style.ts)
  else if (zone === "start") set("0 auto", "0", share, "auto");
  else if (zone === "end") set("auto 0", "0", share, "auto");
  else if (zone === "top") set("0", "0 auto", "auto", share);
  else set("0", "auto 0", "auto", share);
  hint.dataset.zone = zone;
  hint.hidden = false;
}

// --- 共通 ---------------------------------------------------------------------

/** 引きずられているのが、この窓の、足せるタブなら、そのタブ */
function dragged(win: ChromeWindow, ev: DragEvent): XULTab | null {
  const dt = ev.dataTransfer as TabTransfer | null;
  if (!dt || !Array.from(dt.types).includes(TAB_FLAVOR)) return null;
  const tab = dt.mozGetDataAt(TAB_FLAVOR, 0) as XULTab | null;
  if (!tab) return null;
  // **この口から来るのは、タブとは限らない。** タブグループのラベルも、分割ビューの
  // 束も、同じ "application/x-moz-tabbrowser-tab" に載る(本体は isTabGroupLabel で
  // 分けている)。タブでないものを addTabs に渡すと moveTabToSplitView が投げ、
  // そのとき束の数えている中身にはもう push されているので、束が壊れたまま残る
  // ── 以後その束を出すたびに _insertBrowser が転ぶ。
  if (tab.localName !== "tab") return null;
  // ほかの窓から来たタブは本体の作法(adoptTab)が要るので、ここでは受けない
  if (tab.ownerDocument !== win.document || tab.pinned) return null;
  return tab;
}
splitview/layout.ts · 165 lines
// SPDX-License-Identifier: MPL-2.0

// 決めるところ。窓も pref も DOM も知らない ── 「ペインが n 枚、並べかたはこれ、
// 境はここ」から「grid をどう組むか」と「どれをどこに置くか」を出すだけ。
//
// 本体の分割ビューは flex で、CSS は column="0" と column="1" の order しか
// 知らない。三枚目には行き場が無い。grid に替えると、置き場所は line の番号で
// 一枚ずつ言えるので、order も DOM の順も要らなくなる ── そして境が track に
// なるので、ドラッグは「その track の幅を書き換える」ことになって、動かしている
// 最中にそのまま中身がついてくる。
//
// 境は 0..1 の位置で持つ(「左から何割のところ」)。幅そのものでないのは、窓の
// 大きさが変わっても割合のままでいてほしいから。

/** 並べかた。grid は三枚以上でだけ意味を持つ(二枚の格子は横並びと同じもの) */
export type Layout = "columns" | "rows" | "grid";

export const LAYOUTS: Layout[] = ["columns", "rows", "grid"];

/** 何枚まで。本体の分割ビューは枚数を制限していないが、四枚より先は畳が小さすぎる */
export const MAX_PANES = 4;

/** 境の太さ。掴める幅であって、見える幅ではない(見えるのは真ん中の細い線) */
export const GRIP = "6px";

/** grid の中の居場所。line の番号か "1 / -1"(端から端まで) */
export interface Place {
  column: string;
  row: string;
}

/** 境ひとつ。axis はどちらの向きに動くか、index は edges の何本目か */
export interface Grip {
  axis: "col" | "row";
  index: number;
  place: Place;
}

/** 境の位置たち。列の境と行の境を別々に持つ(格子は縦一本と横一本を使う) */
export interface Edges {
  cols: number[];
  rows: number[];
}

/** その枚数で、その並べかたが実際に何になるか */
export function effective(layout: Layout, panes: number): Layout {
  if (panes < 3 && layout === "grid") return "columns";
  return layout;
}

/** その並べかたと枚数のとき、境が何本ずついるか */
export function edgeCount(layout: Layout, panes: number): { cols: number; rows: number } {
  const shape = effective(layout, panes);
  if (shape === "columns") return { cols: panes - 1, rows: 0 };
  if (shape === "rows") return { cols: 0, rows: panes - 1 };
  return { cols: 1, rows: 1 };
}

/** 何も言われていないときの境: 等分 */
export function evenEdges(layout: Layout, panes: number): Edges {
  const want = edgeCount(layout, panes);
  return { cols: even(want.cols), rows: even(want.rows) };
}

function even(count: number): number[] {
  const out: number[] = [];
  for (let i = 1; i <= count; i++) out.push(i / (count + 1));
  return out;
}

/**
 * 覚えていた境を、いまの形に合わせる。本数が違えば等分に戻す ── 二枚から四枚へ
 * 変えたときに、前の一本を引き伸ばして使うより、そのほうが素直。
 */
export function fitEdges(layout: Layout, panes: number, held: Edges): Edges {
  const want = edgeCount(layout, panes);
  const fresh = evenEdges(layout, panes);
  return {
    cols: held.cols.length === want.cols ? held.cols : fresh.cols,
    rows: held.rows.length === want.rows ? held.rows : fresh.rows,
  };
}

/** grid-template-columns / -rows。区画は fr で分ける(境の太さを引いた残りの取り合い) */
export function template(layout: Layout, panes: number, edges: Edges): { columns: string; rows: string } {
  const shape = effective(layout, panes);
  if (shape === "columns") return { columns: track(edges.cols), rows: "minmax(0, 1fr)" };
  if (shape === "rows") return { columns: "minmax(0, 1fr)", rows: track(edges.rows) };
  return { columns: track(edges.cols), rows: track(edges.rows) };
}

/**
 * 境の位置から track の並びへ。`minmax(0, Xfr)` なのは、素の `Xfr` だと中身の
 * min-content より小さくなれない ── ページが縮まるのを拒んで比が守られなくなる。
 */
function track(edges: number[]): string {
  const parts: string[] = [];
  let prev = 0;
  for (const edge of edges) {
    parts.push(fr(edge - prev), GRIP);
    prev = edge;
  }
  parts.push(fr(1 - prev));
  return parts.join(" ");
}

function fr(size: number): string {
  return `minmax(0, ${Math.max(size, 0).toFixed(4)}fr)`;
}

/** i 枚目のペインの居場所。i は本体が付ける column 属性(0 から) */
export function paneAt(layout: Layout, panes: number, i: number): Place {
  const shape = effective(layout, panes);
  if (shape === "columns") return { column: line(i), row: "1 / -1" };
  if (shape === "rows") return { column: "1 / -1", row: line(i) };
  // 格子。三枚のときは 0 枚目が左を全部もらう(主のひとつと、それに従う二つ)
  if (panes <= 3) {
    if (i === 0) return { column: "1", row: "1 / -1" };
    return { column: "3", row: line(i - 1) };
  }
  return { column: line(i % 2), row: line(Math.floor(i / 2)) };
}

/** 境たち。ペインの間に一本ずつ、格子なら縦と横に一本ずつ */
export function grips(layout: Layout, panes: number): Grip[] {
  const shape = effective(layout, panes);
  const out: Grip[] = [];
  if (shape === "columns") {
    for (let i = 0; i < panes - 1; i++) {
      out.push({ axis: "col", index: i, place: { column: gap(i), row: "1 / -1" } });
    }
    return out;
  }
  if (shape === "rows") {
    for (let i = 0; i < panes - 1; i++) {
      out.push({ axis: "row", index: i, place: { column: "1 / -1", row: gap(i) } });
    }
    return out;
  }
  out.push({ axis: "col", index: 0, place: { column: "2", row: "1 / -1" } });
  // 三枚の格子では、横の境は右の列にしかない(左のペインは上下に割れていない)
  out.push({ axis: "row", index: 0, place: { column: panes <= 3 ? "3" : "1 / -1", row: "2" } });
  return out;
}

/** i 番目の区画が始まる line。区画と境が交互なので、一つ飛ばしに数える */
function line(i: number): string {
  return String(i * 2 + 1);
}

/** i 番目の境の line */
function gap(i: number): string {
  return String(i * 2 + 2);
}

/** 掴んだ境を、掴んだところへ。両隣の境より近づきすぎないように止める */
export function moveEdge(edges: number[], index: number, to: number): number[] {
  const least = 0.08;
  const low = (edges[index - 1] ?? 0) + least;
  const high = (edges[index + 1] ?? 1) - least;
  const next = [...edges];
  next[index] = Math.min(Math.max(to, low), high);
  return next;
}
splitview/style.ts · 222 lines
// SPDX-License-Identifier: MPL-2.0

// この drop の CSS。本体のスタイルシートを消しに行くのではなく、[nora-split] が
// 付いている間だけ上に重ねる ── 属性が外れれば、元の二枚の分割ビューがそのまま
// 戻る。
//
// 覚えておくこと二つ(chrome://global/content/xul.css):
//   - tabpanels の子は既定で「見えないが読み上げには残る」(-moz-subtree-hidden-only-visually)。
//     例外は .deck-selected / .split-view-panel-active / .split-view-splitter の三つだけなので、
//     こちらで置く境も、同じことを自分で言う。
//   - tabpanels の子は grid-area: 1 / 1 ── 何も言わなければ全部が一枚に重なる。
//     置き場所は io/panels.ts が一枚ずつ inline で書く。

export const STYLE = `
/* 本体は #tabbrowser-tabpanels を display: flex にしている。分割ビューが出ている
   間だけ grid に替える(枚数と並べかたは grid-template が言う) */
#tabbrowser-tabpanels[nora-split] {
  display: grid;
}

/* 本体の splitter は「DOM の前の兄弟の width 属性」を動かす仕組みで、grid の
   並びとは噛み合わない。境は .nora-split-grip が持つ */
#tabbrowser-tabpanels[nora-split] > .split-view-splitter {
  display: none;
}

/* ペイン。flex を前提にした指定(幅の 49.4%、min/max-width、片側だけ潰した margin)を
   grid のものに置き換える。width は本体が inline で書き戻すことがあるので !important */
#tabbrowser-tabpanels[nora-split] > .split-view-panel[column] {
  min-width: 0;
  max-width: none;
  width: auto !important;
  margin: var(--space-xsmall);
}

/* 掴んでいる間、ページには手を出させない。捕まえてはいるが、ページの上を通ると
   カーソルがそちらの言うなりになる(境を持っているのに I ビームになる) */
#tabbrowser-tabpanels[nora-split-dragging] > .split-view-panel {
  pointer-events: none;
}

/* --- 境 ------------------------------------------------------------------ */

#tabbrowser-tabpanels > .nora-split-grip {
  -moz-subtree-hidden-only-visually: 0;
  visibility: inherit;
  z-index: 3;
  align-items: center;
  justify-content: center;
  -moz-user-focus: normal;
}

#tabbrowser-tabpanels > .nora-split-grip[data-axis="col"] {
  cursor: col-resize;
}

#tabbrowser-tabpanels > .nora-split-grip[data-axis="row"] {
  cursor: row-resize;
}

/* 掴めるのは 6px、見えるのは真ん中の 2px。掴みやすさと、細くありたいのは別のこと */
#tabbrowser-tabpanels > .nora-split-grip::before {
  content: "";
  display: block;
  border-radius: var(--border-radius-small, 4px);
  background-color: transparent;
  transition: background-color 150ms ease-in-out;
}

#tabbrowser-tabpanels > .nora-split-grip[data-axis="col"]::before {
  width: 2px;
  height: 100%;
}

#tabbrowser-tabpanels > .nora-split-grip[data-axis="row"]::before {
  width: 100%;
  height: 2px;
}

/* --nora-split-tint は、その分割ビューが入っているタブグループの色(io/groups.ts)。
   グループの外なら無いので、そのときは既定の色に落ちる */
#tabbrowser-tabpanels > .nora-split-grip:hover::before,
#tabbrowser-tabpanels > .nora-split-grip:focus-visible::before {
  background-color: var(--nora-split-tint, var(--focus-outline-color));
}

#tabbrowser-tabpanels[nora-split-dragging] > .nora-split-grip::before {
  background-color: var(--nora-split-tint, var(--focus-outline-color));
}

/* いま見ているペインの縁も、同じ色で。どのグループを見ているのかが、タブの列を
   見上げなくても分かる */
#tabbrowser-tabpanels[nora-split] > .split-view-panel.deck-selected > .browserContainer {
  outline-color: var(--nora-split-tint, var(--focus-outline-color));
}

/* --- 並べかたを選ぶ絵(#split-view-menu の頭) ----------------------------- */

.nora-split-picker {
  padding: 6px 8px 2px;
  gap: 6px;
}

.nora-split-shape {
  appearance: none;
  padding: 5px;
  border-radius: var(--border-radius-small, 4px);
  border: 1px solid transparent;
}

.nora-split-shape:hover {
  background-color: color-mix(in srgb, currentColor 10%, transparent);
}

.nora-split-shape[selected="true"] {
  border-color: var(--focus-outline-color);
}

/* 絵そのもの。いまの枚数で、その並べかたが実際どう見えるか */
.nora-split-shape-art {
  display: grid;
  gap: 2px;
  width: 34px;
  height: 24px;
}

.nora-split-shape-cell {
  background-color: currentColor;
  opacity: 0.4;
  border-radius: 2px;
}

.nora-split-shape[selected="true"] .nora-split-shape-cell {
  opacity: 0.9;
}

/* --- ペインの隅の二つ ---------------------------------------------------- */

/* 本体の footer は「いま見ていないほう」にしか出ない。見ているペインのぶんは、
   カーソルが来たときだけ ── 常に何かが乗っているページにはしたくない */
#tabbrowser-tabpanels[nora-split] > .split-view-panel.deck-selected:hover split-view-footer {
  display: flex;
}

split-view-footer .nora-split-act {
  appearance: none;
  padding: 2px;
  border-radius: var(--border-radius-small, 4px);
  -moz-context-properties: fill;
  fill: currentColor;
  opacity: 0;
  transition: opacity 120ms ease-in-out;
}

split-view-footer:hover .nora-split-act,
split-view-footer .nora-split-act:focus-visible {
  opacity: 1;
}

split-view-footer .nora-split-act:hover {
  background-color: color-mix(in srgb, currentColor 14%, transparent);
}

split-view-footer .nora-split-act .toolbarbutton-icon {
  width: 14px;
  height: 14px;
}

/* --- 掴んでいる間、分割ビューを出したままにする ---------------------------- */

/* タブを掴むとそのタブが選ばれ、本体は分割ビューを畳む。そのままでは、落とす先が
   見えないところに矩形を描くことになる。出し直したぶん、掴んだタブ自身のページは
   この間だけ隠す ── 出しておくと、格子の一つ目のセルに重なる(io/tabdrop.ts) */
#tabbrowser-tabpanels[nora-split-held] > .deck-selected:not(.split-view-panel):not([nora-split-peek]) {
  display: none;
}

/* 分割ビューを見ていなかったときは、見ていたページの一枚をそのまま置いておく。
   選択はもう掴んだタブに移っているので、deck が見せてくれるのは向こうのほう ──
   こちらは印で、自分から見えていることにする(tabpanels の子は既定で
   -moz-subtree-hidden-only-visually) */
#tabbrowser-tabpanels[nora-split-held] > [nora-split-peek] {
  -moz-subtree-hidden-only-visually: 0;
  visibility: inherit;
}

/* --- タブを落とす先、その一(ページの上。Vivaldi 式) ----------------------- */

/* 落としたら、そこに座る ── 端なら並ぶ場所、真ん中なら「新しい窓」の絵。
   掴んだタブが前に出ているので、そのページの上に直接描ける */
#tabbrowser-tabpanels > .nora-split-hint {
  -moz-subtree-hidden-only-visually: 0;
  visibility: inherit;
  /* grid item のまま absolute にすると、囲いが一つ目のセルになる。全面を囲いに */
  grid-area: 1 / 1 / -1 / -1;
  position: absolute;
  z-index: 5;
  pointer-events: none;
  margin: var(--space-xsmall);
  border-radius: var(--border-radius-medium, 8px);
  border: 2px solid var(--nora-split-tint, var(--focus-outline-color));
  background-color: color-mix(in srgb, var(--nora-split-tint, var(--focus-outline-color)) 20%, transparent);
}

/* 真ん中に落とすと新しい窓。窓らしく、浮いて見えるように */
#tabbrowser-tabpanels > .nora-split-hint[data-zone="window"] {
  margin: auto;
  border-radius: var(--chrome-block-radius, 12px);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.38);
}

/* --- タブを落とす先、その二(タブの列の、分割ビューの束) ------------------- */

/* 束の上に落とせば、掴んだタブが前に出ていても関係ない。ページの上に落とすほうは
   「掴む前に見ていたもの」を相手にする(io/tabdrop.ts) */
#tabbrowser-tabs tab-split-view-wrapper[nora-split-target] {
  outline: 2px solid var(--focus-outline-color);
  outline-offset: -1px;
  border-radius: var(--border-radius-medium, 8px);
  background-color: color-mix(in srgb, var(--focus-outline-color) 18%, transparent);
}
`;

What a drop is, how it reaches you, and what it is not: f3liz.casa/noraneko/drops/. Served by https://dl.f3liz.casa/drop, signed by https://github.com/f3liz-casa/noraneko-registry/.github/workflows/verify-and-sign.yml@refs/heads/main.

This page is built when you ask for it, out of what https://dl.f3liz.casa/drop is serving right now (2026-09-23T08:38:28.032Z). Drafted by Shiro, an AI assistant, sitting next to nyanrus.