feat: the ribbon's Layout tab
Margins, orientation, page size, columns and breaks as menus; indent and spacing as typed fields. Three of these had no control at all: a section break, and the absolute left and right indent the ruler could only drag. Each margin preset is labelled with the value it sets, so none of them hides a default — and Normal is LibreOffice's 2 cm, the one this editor already uses.
This commit is contained in:
1 parent
b0ae6f09af
commit
5600e84605
6 files changed
+321
-10
No files matched your search
+3
-3
@@ -832,9 +832,9 @@
|
||||
bind:chromeMode
|
||||
bind:documentName
|
||||
bind:showFormattingMarks
|
||||
{pageMargins}
|
||||
{pageOrientation}
|
||||
{pageFormat}
|
||||
bind:pageMargins
|
||||
bind:pageOrientation
|
||||
bind:pageFormat
|
||||
{hfActive}
|
||||
onManageStyles={openStyleManager}
|
||||
onManageTableStyles={() => openStyleManager('table')}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import HistoryButton from '../HistoryButton.svelte';
|
||||
import HomeTab from './tabs/HomeTab.svelte';
|
||||
import InsertTab from './tabs/InsertTab.svelte';
|
||||
import LayoutTab from './tabs/LayoutTab.svelte';
|
||||
import UiLanguagePicker from '../UiLanguagePicker.svelte';
|
||||
import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte';
|
||||
import { t } from '../../i18n/i18n.svelte';
|
||||
@@ -12,7 +13,7 @@
|
||||
import { shortcutHint } from '../../editor/shortcuts';
|
||||
import type { ChromeMode, ThemeMode } from '../../storage/theme';
|
||||
import type { StyleFamily } from '../../styles/styleSheet';
|
||||
import type { PageMargins } from '../../storage/pageMargins';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../../storage/pageMargins';
|
||||
import type { Orientation } from '../../storage/pageOrientation';
|
||||
import type { PageFormat } from '../../storage/pageFormat';
|
||||
import type { HfZone } from '../../storage/headerFooter';
|
||||
@@ -23,9 +24,9 @@
|
||||
chromeMode = $bindable<ChromeMode>('ribbon'),
|
||||
documentName = $bindable(''),
|
||||
showFormattingMarks = $bindable(false),
|
||||
pageMargins,
|
||||
pageOrientation,
|
||||
pageFormat,
|
||||
pageMargins = $bindable(DEFAULT_MARGINS),
|
||||
pageOrientation = $bindable<Orientation>('portrait'),
|
||||
pageFormat = $bindable<PageFormat>('A4'),
|
||||
hfActive = null,
|
||||
onManageStyles,
|
||||
onManageTableStyles,
|
||||
@@ -43,9 +44,9 @@
|
||||
chromeMode?: ChromeMode;
|
||||
documentName?: string;
|
||||
showFormattingMarks?: boolean;
|
||||
pageMargins: PageMargins;
|
||||
pageOrientation: Orientation;
|
||||
pageFormat: PageFormat;
|
||||
pageMargins?: PageMargins;
|
||||
pageOrientation?: Orientation;
|
||||
pageFormat?: PageFormat;
|
||||
hfActive?: HfZone | null;
|
||||
onManageStyles?: (family: StyleFamily) => void;
|
||||
onManageTableStyles?: (family: StyleFamily) => void;
|
||||
@@ -204,6 +205,8 @@
|
||||
<HomeTab {editor} {tick} bind:showFormattingMarks {onManageStyles} {onFind} />
|
||||
{:else if tab === 'insert'}
|
||||
<InsertTab {editor} {tick} {hfActive} {pageMargins} {pageOrientation} {pageFormat} {onEditZone} {onManageTableStyles} />
|
||||
{:else if tab === 'layout'}
|
||||
<LayoutTab {editor} {tick} {hfActive} bind:pageMargins bind:pageOrientation bind:pageFormat />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -57,6 +57,10 @@ export const STROKED = {
|
||||
|
||||
find: ['M7.25 12.5a5.25 5.25 0 1 0 0-10.5 5.25 5.25 0 0 0 0 10.5z', 'M11.2 11.2 14 14'],
|
||||
replace: ['M2 4.5h6.5M2 4.5 4 2.5M2 4.5l2 2', 'M14 11.5H7.5M14 11.5l-2-2M14 11.5l-2 2'],
|
||||
margins: ['M1.75 1.75h12.5v12.5H1.75z', 'M4.5 1.75v12.5M11.5 1.75v12.5', 'M1.75 4.5h12.5M1.75 11.5h12.5'],
|
||||
orientation: ['M2.5 2h7v12h-7z', 'M11 6.5h3v6h-3z'],
|
||||
pageSize: ['M2.5 1.75h11v12.5h-11z', 'M5 5h6M5 8h6M5 11h4'],
|
||||
columns: ['M2 2.5h4.5v11H2zM9.5 2.5H14v11H9.5z'],
|
||||
image: ['M2 3.25h12v9.5H2z', 'M2 10.5 5.75 7l3 2.75L11 7.5l3 3', 'M10.6 6.4a1.1 1.1 0 1 0 0-2.2 1.1 1.1 0 0 0 0 2.2z'],
|
||||
textBox: ['M1.75 4h12.5v8H1.75z', 'M5 6.5h6M5 9h4'],
|
||||
link: ['M6.6 9.4 9.4 6.6', 'M7.4 4.4 9 2.8a2.7 2.7 0 0 1 3.8 3.8l-1.6 1.6', 'M8.6 11.6 7 13.2a2.7 2.7 0 0 1-3.8-3.8l1.6-1.6'],
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import RibbonGroup from '../RibbonGroup.svelte';
|
||||
import RibbonButton from '../RibbonButton.svelte';
|
||||
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
|
||||
import { uniformBlockAttr } from '../../../utils/selectionFormat';
|
||||
import { findColumns, DEFAULT_COLUMN_GAP_CM } from '../../../editor/extensions/columns';
|
||||
import { PAGE_FORMAT_CM, type PageFormat } from '../../../storage/pageFormat';
|
||||
import { DEFAULT_MARGINS, type PageMargins } from '../../../storage/pageMargins';
|
||||
import type { Orientation } from '../../../storage/pageOrientation';
|
||||
import type { HfZone } from '../../../storage/headerFooter';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
import { shortcutHint } from '../../../editor/shortcuts';
|
||||
|
||||
let {
|
||||
editor, tick, hfActive = null,
|
||||
pageMargins = $bindable(DEFAULT_MARGINS),
|
||||
pageOrientation = $bindable<Orientation>('portrait'),
|
||||
pageFormat = $bindable<PageFormat>('A4'),
|
||||
}: {
|
||||
editor: Editor | null;
|
||||
tick: number;
|
||||
hfActive?: HfZone | null;
|
||||
pageMargins?: PageMargins;
|
||||
pageOrientation?: Orientation;
|
||||
pageFormat?: PageFormat;
|
||||
} = $props();
|
||||
|
||||
const FORMATS = Object.keys(PAGE_FORMAT_CM) as PageFormat[];
|
||||
const EDGES = ['top', 'bottom', 'left', 'right'] as const;
|
||||
|
||||
// Named after the value they set, so no preset hides what it does.
|
||||
const MARGIN_PRESETS: { key: 'normal' | 'narrow' | 'wide'; m: PageMargins }[] = [
|
||||
{ key: 'normal', m: { top: 2, bottom: 2, left: 2, right: 2 } },
|
||||
{ key: 'narrow', m: { top: 1.27, bottom: 1.27, left: 1.27, right: 1.27 } },
|
||||
{ key: 'wide', m: { top: 2.54, bottom: 2.54, left: 5.08, right: 5.08 } },
|
||||
];
|
||||
|
||||
const fmtCm = (v: number) => (Math.round(v * 100) / 100).toString().replace('.', ',');
|
||||
|
||||
function setMargin(edge: (typeof EDGES)[number], raw: string) {
|
||||
const v = parseFloat(raw.replace(',', '.'));
|
||||
if (isNaN(v)) return;
|
||||
pageMargins = { ...pageMargins, [edge]: Math.min(10, Math.max(0, v)) };
|
||||
}
|
||||
|
||||
// --- Columns ---
|
||||
let colState = $derived.by(() => {
|
||||
if (tick < 0 || !editor) return { count: 1, gap: DEFAULT_COLUMN_GAP_CM, inColumns: false };
|
||||
const found = findColumns(editor.state);
|
||||
return found
|
||||
? { count: found.node.attrs.count as number, gap: found.node.attrs.gap as number, inColumns: true }
|
||||
: { count: 1, gap: DEFAULT_COLUMN_GAP_CM, inColumns: false };
|
||||
});
|
||||
|
||||
function setColumns(n: number) {
|
||||
closeMenu();
|
||||
editor?.chain().focus().setColumns(n).run();
|
||||
}
|
||||
|
||||
// --- Paragraph indent and spacing, in the units Word's Layout tab uses ---
|
||||
let indentLeft = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'indent', 0) : 0);
|
||||
let indentRight = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'indentRight', 0) : 0);
|
||||
let spaceBefore = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'spaceBefore', 0) : 0);
|
||||
let spaceAfter = $derived(tick >= 0 && editor ? uniformBlockAttr<number>(editor.state, 'spaceAfter', 0) : 0);
|
||||
|
||||
const num = (v: number | '') => (v === '' ? '' : String(Math.round(v * 100) / 100));
|
||||
|
||||
function apply(fn: (v: number) => void, raw: string) {
|
||||
const v = parseFloat(raw.replace(',', '.'));
|
||||
if (!isNaN(v)) fn(v);
|
||||
}
|
||||
</script>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.pageSetup}>
|
||||
<div class="rb-menu-wrap" use:clickOutside={'margins'}>
|
||||
<RibbonButton variant="big" icon="margins" label={t().ribbon.margins} title={t().toolbarExpanded.pageMargins} caret caretActive={isMenuOpen('margins')} onclick={() => toggleMenu('margins')} onCaret={() => toggleMenu('margins')} />
|
||||
{#if isMenuOpen('margins')}
|
||||
<div class="ribbon-menu margin-menu" use:anchored role="menu">
|
||||
{#each MARGIN_PRESETS as p}
|
||||
<button onclick={() => { closeMenu(); pageMargins = p.m; }}>
|
||||
{t().ribbon.marginPresets[p.key]}
|
||||
<span class="menu-sub">{fmtCm(p.m.top)} / {fmtCm(p.m.left)} cm</span>
|
||||
</button>
|
||||
{/each}
|
||||
<hr />
|
||||
<div class="rb-menu-label">{t().toolbarExpanded.pageMargins}</div>
|
||||
<div class="margin-grid">
|
||||
{#each EDGES as edge}
|
||||
<label class="margin-field">
|
||||
<span>{t().toolbarExpanded.margins[edge]}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={fmtCm(pageMargins[edge])}
|
||||
title={t().toolbarExpanded.marginField(t().toolbarExpanded.margins[edge])}
|
||||
onchange={(e) => setMargin(edge, (e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rb-menu-wrap" use:clickOutside={'orientation'}>
|
||||
<RibbonButton variant="big" icon="orientation" label={t().toolbarExpanded.orientation} title={t().toolbarExpanded.orientation} caret caretActive={isMenuOpen('orientation')} onclick={() => toggleMenu('orientation')} onCaret={() => toggleMenu('orientation')} />
|
||||
{#if isMenuOpen('orientation')}
|
||||
<div class="ribbon-menu" use:anchored role="menu">
|
||||
{#each (['portrait', 'landscape'] as const) as o}
|
||||
<button class:selected={pageOrientation === o} onclick={() => { closeMenu(); pageOrientation = o; }}>{t().toolbarExpanded[o]}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rb-menu-wrap" use:clickOutside={'pageFormat'}>
|
||||
<RibbonButton variant="big" icon="pageSize" label={t().ribbon.size} title={t().toolbarExpanded.pageFormat} caret caretActive={isMenuOpen('pageFormat')} onclick={() => toggleMenu('pageFormat')} onCaret={() => toggleMenu('pageFormat')} />
|
||||
{#if isMenuOpen('pageFormat')}
|
||||
<div class="ribbon-menu format-menu" use:anchored role="menu">
|
||||
<div class="menu-scroll">
|
||||
{#each FORMATS as f}
|
||||
<button class:selected={pageFormat === f} onclick={() => { closeMenu(); pageFormat = f; }}>
|
||||
{t().toolbarExpanded.pageFormats[f]}
|
||||
<span class="menu-sub">{fmtCm(PAGE_FORMAT_CM[f].w)} × {fmtCm(PAGE_FORMAT_CM[f].h)} cm</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rb-menu-wrap" use:clickOutside={'columns'}>
|
||||
<RibbonButton
|
||||
variant="big"
|
||||
icon="columns"
|
||||
label={t().toolbarExpanded.columns}
|
||||
title={hfActive ? t().toolbarExpanded.columnsNotInHf : t().toolbarExpanded.columns}
|
||||
disabled={!editor || !!hfActive}
|
||||
caret
|
||||
caretActive={isMenuOpen('columns')}
|
||||
onclick={() => toggleMenu('columns')}
|
||||
onCaret={() => toggleMenu('columns')}
|
||||
/>
|
||||
{#if isMenuOpen('columns')}
|
||||
<div class="ribbon-menu" use:anchored role="menu">
|
||||
{#each [1, 2, 3] as n}
|
||||
<button class:selected={colState.count === n} onclick={() => setColumns(n)}>
|
||||
{n === 1 ? t().toolbarExpanded.columnsOne : n === 2 ? t().toolbarExpanded.columnsTwo : t().toolbarExpanded.columnsThree}
|
||||
</button>
|
||||
{/each}
|
||||
<hr />
|
||||
<label class="gap-field">
|
||||
<span>{t().toolbarExpanded.columnGap}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={fmtCm(colState.gap)}
|
||||
disabled={!colState.inColumns}
|
||||
onchange={(e) => apply((v) => editor?.chain().focus().setColumnGap(v).run(), (e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rb-menu-wrap" use:clickOutside={'breaks'}>
|
||||
<RibbonButton
|
||||
variant="big"
|
||||
icon="pageBreak"
|
||||
label={t().ribbon.breaks}
|
||||
title={t().ribbon.breaks}
|
||||
disabled={!editor || !!hfActive}
|
||||
caret
|
||||
caretActive={isMenuOpen('breaks')}
|
||||
onclick={() => toggleMenu('breaks')}
|
||||
onCaret={() => toggleMenu('breaks')}
|
||||
/>
|
||||
{#if isMenuOpen('breaks')}
|
||||
<div class="ribbon-menu breaks-menu" use:anchored role="menu">
|
||||
<button onclick={() => { closeMenu(); editor?.chain().focus().insertPageBreak().run(); }}>
|
||||
{t().ribbon.pageBreak}
|
||||
<span class="menu-key">{shortcutHint('pageBreak')}</span>
|
||||
</button>
|
||||
<button onclick={() => { closeMenu(); editor?.chain().focus().updateAttributes('paragraph', { sectionBreak: true }).run(); }}>
|
||||
{t().ribbon.sectionBreak}
|
||||
<span class="menu-sub">{t().ribbon.sectionBreakHint}</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<!-- Absolute indent and spacing: the ruler can drag these, but only here can a
|
||||
value be typed. -->
|
||||
<RibbonGroup label={t().ribbon.groups.paragraph}>
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>{t().ribbon.indentLeft}</span>
|
||||
<input type="text" inputmode="decimal" value={num(indentLeft)} disabled={!editor}
|
||||
onchange={(e) => apply((v) => editor?.chain().focus().setIndent(v).run(), (e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{t().ribbon.spaceBefore}</span>
|
||||
<input type="text" inputmode="decimal" value={num(spaceBefore)} disabled={!editor}
|
||||
onchange={(e) => apply((v) => editor?.chain().focus().setSpaceBefore(v).run(), (e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{t().ribbon.indentRight}</span>
|
||||
<input type="text" inputmode="decimal" value={num(indentRight)} disabled={!editor}
|
||||
onchange={(e) => apply((v) => editor?.chain().focus().setIndentRight(v).run(), (e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{t().ribbon.spaceAfter}</span>
|
||||
<input type="text" inputmode="decimal" value={num(spaceAfter)} disabled={!editor}
|
||||
onchange={(e) => apply((v) => editor?.chain().focus().setSpaceAfter(v).run(), (e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<style>
|
||||
.rb-menu-wrap { position: relative; }
|
||||
|
||||
.margin-menu, .format-menu, .breaks-menu { min-width: 230px; }
|
||||
.format-menu { max-height: 340px; overflow: hidden; }
|
||||
|
||||
.margin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 2px 7px 4px;
|
||||
}
|
||||
|
||||
.margin-field, .gap-field, .field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--w-font);
|
||||
font-size: 12px;
|
||||
color: var(--w-text-dim);
|
||||
}
|
||||
|
||||
.gap-field { padding: 4px 12px; }
|
||||
|
||||
.margin-field input, .gap-field input, .field input {
|
||||
width: 52px;
|
||||
height: 24px;
|
||||
border: 1px solid var(--w-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--w-surface);
|
||||
padding: 0 5px;
|
||||
color: var(--w-text);
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.margin-field input:focus, .gap-field input:focus, .field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--w-accent);
|
||||
}
|
||||
|
||||
/* Word's Layout tab puts indent and spacing side by side, two rows each. */
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
align-content: center;
|
||||
gap: 3px 12px;
|
||||
}
|
||||
|
||||
.field span { min-width: 74px; }
|
||||
.field input:disabled { opacity: 0.5; }
|
||||
</style>
|
||||
@@ -470,6 +470,7 @@ const de: Messages = {
|
||||
headerFooter: 'Kopf- und Fußzeile',
|
||||
text: 'Text',
|
||||
symbols: 'Symbole',
|
||||
pageSetup: 'Seite einrichten',
|
||||
editing: 'Bearbeiten',
|
||||
},
|
||||
case: {
|
||||
@@ -500,6 +501,20 @@ const de: Messages = {
|
||||
table: 'Tabelle',
|
||||
dateTime: 'Datum & Uhrzeit',
|
||||
symbol: 'Symbol',
|
||||
margins: 'Seitenränder',
|
||||
size: 'Größe',
|
||||
breaks: 'Umbrüche',
|
||||
sectionBreak: 'Abschnittsumbruch',
|
||||
sectionBreakHint: 'Beginnt eine eigene Kopf- und Fußzeile',
|
||||
indentLeft: 'Einzug links',
|
||||
indentRight: 'Einzug rechts',
|
||||
spaceBefore: 'Abstand davor',
|
||||
spaceAfter: 'Abstand danach',
|
||||
marginPresets: {
|
||||
normal: 'Normal',
|
||||
narrow: 'Schmal',
|
||||
wide: 'Breit',
|
||||
},
|
||||
chrome: {
|
||||
title: 'Symbolleisten-Stil',
|
||||
ribbon: 'Menüband',
|
||||
|
||||
@@ -474,6 +474,7 @@ const en = {
|
||||
headerFooter: 'Header & Footer',
|
||||
text: 'Text',
|
||||
symbols: 'Symbols',
|
||||
pageSetup: 'Page Setup',
|
||||
editing: 'Editing',
|
||||
},
|
||||
case: {
|
||||
@@ -504,6 +505,20 @@ const en = {
|
||||
table: 'Table',
|
||||
dateTime: 'Date & time',
|
||||
symbol: 'Symbol',
|
||||
margins: 'Margins',
|
||||
size: 'Size',
|
||||
breaks: 'Breaks',
|
||||
sectionBreak: 'Section break',
|
||||
sectionBreakHint: 'Starts its own header and footer',
|
||||
indentLeft: 'Indent left',
|
||||
indentRight: 'Indent right',
|
||||
spaceBefore: 'Space before',
|
||||
spaceAfter: 'Space after',
|
||||
marginPresets: {
|
||||
normal: 'Normal',
|
||||
narrow: 'Narrow',
|
||||
wide: 'Wide',
|
||||
},
|
||||
chrome: {
|
||||
title: 'Toolbar style',
|
||||
ribbon: 'Ribbon',
|
||||
|
||||
Reference in new issue
Block a user