feat: the ribbon's contextual tabs for a table, a picture and a shape
They appear while the caret is in the object and go with it, handing the strip back to Home. Table Design carries the style gallery, shading, borders and the style options; Table Layout the rows, columns, merge, split, alignment — and cell margins, which round-tripped with nothing to set them. The floating toolbars stay: a mini-toolbar beside the object and a contextual tab above the document are both Word's, and both reuse the same pickers.
This commit is contained in:
1 parent
0f53571b1f
commit
ad3f0507fd
6 files changed
+542
-3
No files matched your search
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { NodeSelection } from '@tiptap/pm/state';
|
||||
import Icon from './Icon.svelte';
|
||||
import RibbonMenu from './RibbonMenu.svelte';
|
||||
import HistoryButton from '../HistoryButton.svelte';
|
||||
@@ -9,6 +10,8 @@
|
||||
import ReferencesTab from './tabs/ReferencesTab.svelte';
|
||||
import ReviewTab from './tabs/ReviewTab.svelte';
|
||||
import ViewTab from './tabs/ViewTab.svelte';
|
||||
import TableTabs from './tabs/TableTabs.svelte';
|
||||
import FrameTabs from './tabs/FrameTabs.svelte';
|
||||
import UiLanguagePicker from '../UiLanguagePicker.svelte';
|
||||
import { clickOutside, isMenuOpen, toggleMenu, closeMenu } from './menu.svelte';
|
||||
import { t } from '../../i18n/i18n.svelte';
|
||||
@@ -85,11 +88,39 @@
|
||||
} = $props();
|
||||
|
||||
const TABS = ['home', 'insert', 'layout', 'references', 'review', 'view'] as const;
|
||||
type Tab = (typeof TABS)[number];
|
||||
const CONTEXTUAL = ['tableDesign', 'tableLayout', 'pictureFormat', 'shapeFormat'] as const;
|
||||
type Tab = (typeof TABS)[number] | (typeof CONTEXTUAL)[number];
|
||||
|
||||
// Word opens on Home every time, so the active tab is not persisted.
|
||||
let tab = $state<Tab>('home');
|
||||
|
||||
// Word's contextual tabs: they appear while the caret is in the object and
|
||||
// vanish with it, so a tab that disappears hands the strip back to Home.
|
||||
let inTable = $derived(tick >= 0 && !!editor?.isActive('table'));
|
||||
|
||||
let selectedNode = $derived.by<string | null>(() => {
|
||||
if (tick < 0 || !editor) return null;
|
||||
const sel = editor.state.selection;
|
||||
return sel instanceof NodeSelection ? sel.node.type.name : null;
|
||||
});
|
||||
|
||||
let frameAttrs = $derived.by<Record<string, unknown> | null>(() => {
|
||||
if (tick < 0 || !editor) return null;
|
||||
const sel = editor.state.selection;
|
||||
return sel instanceof NodeSelection ? sel.node.attrs : null;
|
||||
});
|
||||
|
||||
let shown = $derived([
|
||||
...TABS,
|
||||
...(inTable ? (['tableDesign', 'tableLayout'] as const) : []),
|
||||
...(selectedNode === 'image' ? (['pictureFormat'] as const) : []),
|
||||
...(selectedNode === 'textBox' ? (['shapeFormat'] as const) : []),
|
||||
] as Tab[]);
|
||||
|
||||
$effect(() => {
|
||||
if (!shown.includes(tab)) tab = 'home';
|
||||
});
|
||||
|
||||
let docNameSizerWidth = $state(0);
|
||||
|
||||
function run(fn?: () => void) {
|
||||
@@ -159,8 +190,13 @@
|
||||
<HistoryButton {editor} {tick} direction="redo" />
|
||||
<span class="qa-sep"></span>
|
||||
|
||||
{#each TABS as id}
|
||||
<button class="ribbon-tab" class:active={tab === id} onclick={() => (tab = id)}>
|
||||
{#each shown as id}
|
||||
<button
|
||||
class="ribbon-tab"
|
||||
class:active={tab === id}
|
||||
class:contextual={!(TABS as readonly string[]).includes(id)}
|
||||
onclick={() => (tab = id)}
|
||||
>
|
||||
{t().ribbon.tabs[id]}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -229,6 +265,18 @@
|
||||
<ReviewTab {editor} {tick} {documentLanguage} {onLanguage} />
|
||||
{:else if tab === 'view'}
|
||||
<ViewTab bind:showRuler bind:showFormattingMarks {zoom} {onZoom} {onDebugDump} />
|
||||
{:else if tab === 'tableDesign' || tab === 'tableLayout'}
|
||||
<TableTabs {editor} {tick} which={tab === 'tableDesign' ? 'design' : 'layout'} />
|
||||
{:else if tab === 'pictureFormat' || tab === 'shapeFormat'}
|
||||
<FrameTabs
|
||||
{editor}
|
||||
which={tab === 'pictureFormat' ? 'picture' : 'shape'}
|
||||
wrap={(frameAttrs?.wrap ?? 'inline') as never}
|
||||
shapeKind={frameAttrs?.shapeKind as never}
|
||||
fillColor={frameAttrs?.fillColor as string | null}
|
||||
strokeColor={frameAttrs?.strokeColor as string | null}
|
||||
strokeWidthPt={frameAttrs?.strokeWidthPt as number}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,6 +327,18 @@
|
||||
}
|
||||
|
||||
.ribbon-tab:hover { color: var(--w-accent); }
|
||||
|
||||
/* Word tints a contextual tab so it reads as belonging to the selected object
|
||||
rather than to the document. */
|
||||
.ribbon-tab.contextual { color: var(--w-accent-dark); }
|
||||
.ribbon-tab.contextual::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 2px 4px auto;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--w-accent-soft);
|
||||
}
|
||||
.ribbon-tab.active { color: var(--w-accent); }
|
||||
|
||||
/* Word's underline is inset from the tab's edges, not full width. */
|
||||
|
||||
@@ -55,6 +55,28 @@ export const STROKED = {
|
||||
highlighter: ['M5.5 9.5 10 5l3 3-4.5 4.5H5.5z', 'M9.25 3.25 12.75 6.75', 'M2.5 13.5h4'],
|
||||
shading: ['M4 8.5 8.5 4l4 4-4.5 4.5a1.4 1.4 0 0 1-2 0L4 10.5a1.4 1.4 0 0 1 0-2z', 'M6.5 2 8.5 4', 'M2.5 13.5h11'],
|
||||
|
||||
rowAbove: ['M1.5 9h13v5.5h-13z', 'M1.5 11.75h13', 'M8 1.5v5M5.5 4h5'],
|
||||
rowBelow: ['M1.5 1.5h13V7h-13z', 'M1.5 4.25h13', 'M8 9.5v5M5.5 12h5'],
|
||||
colLeft: ['M9 1.5h5.5v13H9z', 'M11.75 1.5v13', 'M1.5 8h5M4 5.5v5'],
|
||||
colRight: ['M1.5 1.5H7v13H1.5z', 'M4.25 1.5v13', 'M9.5 8h5M12 5.5v5'],
|
||||
deleteRow: ['M1.5 5.5h13v5h-13z', 'M4 3 12 13M12 3 4 13'],
|
||||
deleteCol: ['M5.5 1.5h5v13h-5z', 'M3 4 13 12M13 4 3 12'],
|
||||
deleteTable: ['M2 2.5h12v11H2z', 'M2 6h12M2 10h12M6 2.5v11M10 2.5v11', 'M3 3 13 13'],
|
||||
merge: ['M2 3h12v10H2z', 'M2 8h4M10 8h4', 'M6.5 6 8.5 8l-2 2M9.5 6 7.5 8l2 2'],
|
||||
split: ['M2 3h12v10H2z', 'M8 3v10', 'M5 6.5 3 8l2 1.5M11 6.5 13 8l-2 1.5'],
|
||||
headerRow: ['M2 3h12v10H2z', 'M2 6.25h12', 'M3.4 4.6h9.2'],
|
||||
firstColumn: ['M2 3h12v10H2z', 'M5.5 3v10', 'M3.75 5v6'],
|
||||
alignTop: ['M2 3h12', 'M8 5.5v7M5.5 8 8 5.5 10.5 8'],
|
||||
alignMiddle: ['M2 8h12', 'M8 2.5v3M8 10.5v3'],
|
||||
alignBottom: ['M2 13h12', 'M8 3.5v7M5.5 8 8 10.5 10.5 8'],
|
||||
cellMargins: ['M2 2.5h12v11H2z', 'M4.5 5h7v6h-7z'],
|
||||
wrapInline: ['M2 4h12M2 8h12M2 12h12'],
|
||||
wrapLeft: ['M2 3.5h6v9H2z', 'M9.5 4.5h4.5M9.5 7h4.5M9.5 9.5h4.5M9.5 12h4.5'],
|
||||
wrapRight: ['M8 3.5h6v9H8z', 'M2 4.5h4.5M2 7h4.5M2 9.5h4.5M2 12h4.5'],
|
||||
wrapTopBottom: ['M4 6.5h8v3H4z', 'M2 3h12M2 13h12'],
|
||||
shapeRect: ['M2 4h12v8H2z'],
|
||||
shapeRound: ['M4 4h8a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z'],
|
||||
shapeEllipse: ['M8 12.5c3.6 0 6.5-2 6.5-4.5S11.6 3.5 8 3.5 1.5 5.5 1.5 8s2.9 4.5 6.5 4.5z'],
|
||||
toc: ['M2 3h6M2 6.5h5M2 10h6M2 13.5h4', 'M11 3h3M10 6.5h4M11 10h3M10 13.5h4'],
|
||||
tocLevels: ['M2.5 3.5h11M4.5 7h9M6.5 10.5h7M8.5 14h5'],
|
||||
wordCount: ['M2.5 2.5h11v11h-11z', 'M5 6h6M5 9h4'],
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import RibbonGroup from '../RibbonGroup.svelte';
|
||||
import RibbonButton from '../RibbonButton.svelte';
|
||||
import ColorPicker from '../../ColorPicker.svelte';
|
||||
import type { WrapMode } from '../../../editor/extensions/image';
|
||||
import type { ShapeKind } from '../../../editor/extensions/textBox';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
|
||||
// Word's Picture Format and Shape Format: the same wrap modes, plus a shape's
|
||||
// own fill, outline and kind.
|
||||
let { editor, which, wrap, shapeKind, fillColor, strokeColor, strokeWidthPt }: {
|
||||
editor: Editor | null;
|
||||
which: 'picture' | 'shape';
|
||||
wrap: WrapMode;
|
||||
shapeKind?: ShapeKind;
|
||||
fillColor?: string | null;
|
||||
strokeColor?: string | null;
|
||||
strokeWidthPt?: number;
|
||||
} = $props();
|
||||
|
||||
const WRAPS: { key: WrapMode; icon: 'wrapInline' | 'wrapLeft' | 'wrapRight' | 'wrapTopBottom'; label: () => string }[] = [
|
||||
{ key: 'inline', icon: 'wrapInline', label: () => t().image.wrapInline },
|
||||
{ key: 'left', icon: 'wrapLeft', label: () => t().image.wrapLeft },
|
||||
{ key: 'right', icon: 'wrapRight', label: () => t().image.wrapRight },
|
||||
{ key: 'topBottom', icon: 'wrapTopBottom', label: () => t().image.wrapTopBottom },
|
||||
];
|
||||
|
||||
const SHAPES: { key: ShapeKind; label: () => string }[] = [
|
||||
{ key: 'textbox', label: () => t().textBox.shapeRect },
|
||||
{ key: 'roundRect', label: () => t().textBox.shapeRoundRect },
|
||||
{ key: 'ellipse', label: () => t().textBox.shapeEllipse },
|
||||
];
|
||||
|
||||
function setWrap(w: WrapMode) {
|
||||
if (which === 'picture') editor?.chain().focus().setImageWrap(w).run();
|
||||
else editor?.chain().focus().setTextBoxAttrs({ wrap: w }).run();
|
||||
}
|
||||
</script>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.arrange}>
|
||||
{#each WRAPS as w}
|
||||
<RibbonButton variant="big" icon={w.icon} label={w.label()} title={w.label()} active={wrap === w.key} onclick={() => setWrap(w.key)} />
|
||||
{/each}
|
||||
</RibbonGroup>
|
||||
|
||||
{#if which === 'shape'}
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.shapeStyles}>
|
||||
{#each SHAPES as s}
|
||||
<RibbonButton variant="big" icon={s.key === 'ellipse' ? 'shapeEllipse' : s.key === 'roundRect' ? 'shapeRound' : 'shapeRect'} label={s.label()} title={s.label()} active={shapeKind === s.key} onclick={() => editor?.chain().focus().setTextBoxAttrs({ shapeKind: s.key }).run()} />
|
||||
{/each}
|
||||
<div class="rb-captioned">
|
||||
<ColorPicker
|
||||
{editor}
|
||||
currentColor={fillColor ?? null}
|
||||
defaultColor="#4472C4"
|
||||
title={t().textBox.fillColor}
|
||||
chevronTitle={t().textBox.fillColor}
|
||||
clearLabel={t().textBox.noFill}
|
||||
onApply={(c) => editor?.chain().focus().setTextBoxAttrs({ fillColor: c }).run()}
|
||||
onClear={() => editor?.chain().focus().setTextBoxAttrs({ fillColor: null }).run()}
|
||||
icon={fillIcon}
|
||||
/>
|
||||
<span class="rb-caption">{t().textBox.fillColor}</span>
|
||||
</div>
|
||||
<div class="rb-captioned">
|
||||
<ColorPicker
|
||||
{editor}
|
||||
currentColor={strokeColor ?? null}
|
||||
defaultColor="#2F5496"
|
||||
title={t().textBox.borderColor}
|
||||
chevronTitle={t().textBox.borderColor}
|
||||
clearLabel={t().textBox.noBorder}
|
||||
onApply={(c) => editor?.chain().focus().setTextBoxAttrs({ strokeColor: c }).run()}
|
||||
onClear={() => editor?.chain().focus().setTextBoxAttrs({ strokeColor: null }).run()}
|
||||
icon={strokeIcon}
|
||||
/>
|
||||
<span class="rb-caption">{t().textBox.borderColor}</span>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>{t().textBox.borderWidth}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={strokeWidthPt ?? 1}
|
||||
onchange={(e) => {
|
||||
const v = parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.'));
|
||||
if (!isNaN(v)) editor?.chain().focus().setTextBoxAttrs({ strokeWidthPt: Math.max(0, v) }).run();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</RibbonGroup>
|
||||
{/if}
|
||||
|
||||
{#snippet fillIcon()}<span class="swatch" style="background: {fillColor ?? 'transparent'}"></span>{/snippet}
|
||||
{#snippet strokeIcon()}<span class="swatch outline" style="border-color: {strokeColor ?? 'currentColor'}"></span>{/snippet}
|
||||
|
||||
<style>
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--w-border-strong);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.swatch.outline { background: transparent; border-width: 2px; }
|
||||
|
||||
.rb-captioned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 7px 6px;
|
||||
}
|
||||
|
||||
.rb-caption, .field {
|
||||
font-family: var(--w-font);
|
||||
font-size: 12px;
|
||||
color: var(--w-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--w-text-dim);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.field input:focus { outline: none; border-color: var(--w-accent); }
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<script lang="ts">
|
||||
import type { Editor, ChainedCommands } from '@tiptap/core';
|
||||
import { CellSelection } from '@tiptap/pm/tables';
|
||||
import RibbonGroup from '../RibbonGroup.svelte';
|
||||
import RibbonButton from '../RibbonButton.svelte';
|
||||
import ColorPicker from '../../ColorPicker.svelte';
|
||||
import TableBorderPicker from '../../TableBorderPicker.svelte';
|
||||
import TableStylePicker from '../../TableStylePicker.svelte';
|
||||
import TableSplitDialog from '../../TableSplitDialog.svelte';
|
||||
import { anchored, clickOutside, isMenuOpen, toggleMenu, closeMenu } from '../menu.svelte';
|
||||
import { isHeaderStyled } from '../../../editor/extensions/tableHeaderRow';
|
||||
import { DEFAULT_CELL_PADDING, parseCellPadding, type CellPadding } from '../../../editor/extensions/tableCellPadding';
|
||||
import type { CellVerticalAlign } from '../../../editor/extensions/tableCellAlign';
|
||||
import { t } from '../../../i18n/i18n.svelte';
|
||||
|
||||
// Word splits the table's contextual tabs in two: Design paints it, Layout
|
||||
// changes its shape.
|
||||
let { editor, tick, which }: {
|
||||
editor: Editor | null;
|
||||
tick: number;
|
||||
which: 'design' | 'layout';
|
||||
} = $props();
|
||||
|
||||
let splitOpen = $state(false);
|
||||
let splitAt = $state({ top: 0, left: 0 });
|
||||
|
||||
function openSplit(e: MouseEvent) {
|
||||
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
splitAt = { top: r.bottom + 4, left: r.left };
|
||||
splitOpen = true;
|
||||
}
|
||||
|
||||
function run(cmd: (chain: ChainedCommands) => ChainedCommands) {
|
||||
if (!editor) return;
|
||||
cmd(editor.chain().focus()).run();
|
||||
}
|
||||
|
||||
const canMerge = $derived(tick >= 0 && !!editor && editor.can().mergeCells());
|
||||
const isHeaderRow = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'row'));
|
||||
const isHeaderCol = $derived(tick >= 0 && !!editor && isHeaderStyled(editor.state, 'column'));
|
||||
|
||||
// One attribute over the selected cells: the shared value, '' if they disagree.
|
||||
function cellAttr(name: string): string | null {
|
||||
if (!editor) return null;
|
||||
const sel = editor.state.selection;
|
||||
const values = new Set<string | null>();
|
||||
if (sel instanceof CellSelection) {
|
||||
sel.forEachCell((cell) => values.add((cell.attrs[name] as string) ?? null));
|
||||
} else {
|
||||
const from = sel.$from;
|
||||
for (let d = from.depth; d > 0; d--) {
|
||||
const role = from.node(d).type.spec.tableRole;
|
||||
if (role === 'cell' || role === 'header_cell') {
|
||||
values.add((from.node(d).attrs[name] as string) ?? null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (values.size === 0) return null;
|
||||
return values.size > 1 ? '' : ([...values][0] ?? null);
|
||||
}
|
||||
|
||||
const cellColor = $derived(tick >= 0 ? cellAttr('backgroundColor') : null);
|
||||
const vAlign = $derived(tick >= 0 ? cellAttr('verticalAlign') : null);
|
||||
// The cell's margins in cm, [top, right, bottom, left]; null outside a table.
|
||||
const padding = $derived.by<CellPadding | null>(() => {
|
||||
if (tick < 0 || !editor || !editor.isActive('table')) return null;
|
||||
// A cell that never set its margins reports none, and shows the format default.
|
||||
return parseCellPadding(cellAttr('cellPadding')) ?? DEFAULT_CELL_PADDING;
|
||||
});
|
||||
|
||||
function setPaddingEdge(i: number, cm: number) {
|
||||
if (!editor || !padding) return;
|
||||
const next = [...padding] as CellPadding;
|
||||
next[i] = cm;
|
||||
editor.chain().focus().setCellAttribute('cellPadding', next).run();
|
||||
}
|
||||
|
||||
const VALIGNS: { key: CellVerticalAlign | null; icon: 'alignTop' | 'alignMiddle' | 'alignBottom'; label: () => string }[] = [
|
||||
{ key: null, icon: 'alignTop', label: () => t().table.cellAlignTop },
|
||||
{ key: 'middle', icon: 'alignMiddle', label: () => t().table.cellAlignMiddle },
|
||||
{ key: 'bottom', icon: 'alignBottom', label: () => t().table.cellAlignBottom },
|
||||
];
|
||||
</script>
|
||||
|
||||
{#if which === 'design'}
|
||||
<RibbonGroup label={t().styles.tableStyles}>
|
||||
<div class="rb-captioned">
|
||||
<TableStylePicker {editor} {tick} />
|
||||
<span class="rb-caption">{t().table.tableStyle}</span>
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.tableDecor}>
|
||||
<div class="rb-captioned">
|
||||
<ColorPicker
|
||||
{editor}
|
||||
currentColor={cellColor}
|
||||
defaultColor="#D9D9D9"
|
||||
title={t().table.cellShading}
|
||||
chevronTitle={t().table.chooseCellColor}
|
||||
clearLabel={t().table.noFill}
|
||||
onApply={(c) => editor?.chain().focus().setCellAttribute('backgroundColor', c).run()}
|
||||
onClear={() => editor?.chain().focus().setCellAttribute('backgroundColor', null).run()}
|
||||
icon={shadeIcon}
|
||||
/>
|
||||
<span class="rb-caption">{t().table.cellShading}</span>
|
||||
</div>
|
||||
<div class="rb-captioned">
|
||||
<TableBorderPicker {editor} {tick} />
|
||||
<span class="rb-caption">{t().borders.title}</span>
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.tableOptions}>
|
||||
<div class="rb-col">
|
||||
<RibbonButton variant="small" icon="headerRow" label={t().styles.regions.headerRow} active={isHeaderRow} onclick={() => run((c) => c.toggleHeaderRowStyle())} />
|
||||
<RibbonButton variant="small" icon="firstColumn" label={t().styles.regions.firstColumn} active={isHeaderCol} onclick={() => run((c) => c.toggleHeaderColumnStyle())} />
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
{:else}
|
||||
<RibbonGroup label={t().ribbon.groups.rowsColumns}>
|
||||
<div class="rb-col">
|
||||
<RibbonButton variant="small" icon="rowAbove" label={t().table.insertRowAbove} onclick={() => run((c) => c.addRowBefore())} />
|
||||
<RibbonButton variant="small" icon="rowBelow" label={t().table.insertRowBelow} onclick={() => run((c) => c.addRowAfter())} />
|
||||
</div>
|
||||
<div class="rb-col">
|
||||
<RibbonButton variant="small" icon="colLeft" label={t().table.insertColumnLeft} onclick={() => run((c) => c.addColumnBefore())} />
|
||||
<RibbonButton variant="small" icon="colRight" label={t().table.insertColumnRight} onclick={() => run((c) => c.addColumnAfter())} />
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().common.remove}>
|
||||
<div class="rb-col">
|
||||
<RibbonButton variant="small" icon="deleteRow" label={t().table.deleteRow} onclick={() => run((c) => c.deleteRow())} />
|
||||
<RibbonButton variant="small" icon="deleteCol" label={t().table.deleteColumn} onclick={() => run((c) => c.deleteColumn())} />
|
||||
<RibbonButton variant="small" icon="deleteTable" label={t().table.deleteTable} onclick={() => run((c) => c.deleteTable())} />
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.merge}>
|
||||
<RibbonButton variant="big" icon="merge" label={t().table.mergeCells} disabled={!canMerge} onclick={() => run((c) => c.mergeCells())} />
|
||||
<span class="split-anchor">
|
||||
<button class="split-trigger" onclick={openSplit} title={t().table.splitCells}>
|
||||
<RibbonButton variant="big" icon="split" label={t().table.splitCellsAria} />
|
||||
</button>
|
||||
<TableSplitDialog
|
||||
open={splitOpen}
|
||||
top={splitAt.top}
|
||||
left={splitAt.left}
|
||||
onApply={(cols, rows) => { splitOpen = false; editor?.chain().focus().splitCellInto(cols, rows).run(); }}
|
||||
onClose={() => (splitOpen = false)}
|
||||
/>
|
||||
</span>
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().align.section}>
|
||||
{#each VALIGNS as v}
|
||||
<RibbonButton
|
||||
icon={v.icon}
|
||||
title={v.label()}
|
||||
active={vAlign === v.key}
|
||||
onclick={() => editor?.chain().focus().setCellAttribute('verticalAlign', v.key).run()}
|
||||
/>
|
||||
{/each}
|
||||
</RibbonGroup>
|
||||
|
||||
<div class="ribbon-sep"></div>
|
||||
|
||||
<RibbonGroup label={t().ribbon.groups.cellSize}>
|
||||
<div class="rb-menu-wrap" use:clickOutside={'cellMargins'}>
|
||||
<RibbonButton
|
||||
variant="big"
|
||||
icon="cellMargins"
|
||||
label={t().ribbon.cellMargins}
|
||||
title={t().ribbon.cellMargins}
|
||||
disabled={!padding}
|
||||
caret
|
||||
caretActive={isMenuOpen('cellMargins')}
|
||||
onclick={() => toggleMenu('cellMargins')}
|
||||
onCaret={() => toggleMenu('cellMargins')}
|
||||
/>
|
||||
{#if isMenuOpen('cellMargins') && padding}
|
||||
<div class="ribbon-menu margin-menu" use:anchored role="menu">
|
||||
<div class="rb-menu-label">{t().ribbon.cellMargins}</div>
|
||||
<div class="margin-grid">
|
||||
{#each (['top', 'right', 'bottom', 'left'] as const) as edge, i}
|
||||
<label class="margin-field">
|
||||
<span>{t().toolbarExpanded.margins[edge]}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
value={padding[i]}
|
||||
onchange={(e) => {
|
||||
const v = parseFloat((e.currentTarget as HTMLInputElement).value.replace(',', '.'));
|
||||
if (!isNaN(v)) setPaddingEdge(i, Math.max(0, v));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</RibbonGroup>
|
||||
{/if}
|
||||
|
||||
{#snippet shadeIcon()}<span class="shade-glyph"></span>{/snippet}
|
||||
|
||||
<style>
|
||||
.rb-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rb-menu-wrap { position: relative; }
|
||||
|
||||
/* The dialog positions itself in viewport coordinates, so the trigger only has
|
||||
to hand it the button's rect. */
|
||||
.split-trigger {
|
||||
display: contents;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.margin-menu { min-width: 210px; }
|
||||
|
||||
.margin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px;
|
||||
padding: 2px 7px 4px;
|
||||
}
|
||||
|
||||
.margin-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--w-font);
|
||||
font-size: 12px;
|
||||
color: var(--w-text-dim);
|
||||
}
|
||||
|
||||
.margin-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 { outline: none; border-color: var(--w-accent); }
|
||||
|
||||
.shade-glyph {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 2px;
|
||||
background: repeating-linear-gradient(45deg, currentColor 0 1px, transparent 1px 3px);
|
||||
}
|
||||
|
||||
.rb-captioned {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 4px 7px 6px;
|
||||
}
|
||||
|
||||
.rb-caption {
|
||||
font-family: var(--w-font);
|
||||
font-size: 12px;
|
||||
color: var(--w-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -476,6 +476,13 @@ const de: Messages = {
|
||||
language: 'Sprache',
|
||||
show: 'Anzeigen',
|
||||
debug: 'Debug',
|
||||
tableDecor: 'Schattierung & Rahmen',
|
||||
tableOptions: 'Tabellenformatoptionen',
|
||||
rowsColumns: 'Zeilen & Spalten',
|
||||
merge: 'Zusammenführen',
|
||||
cellSize: 'Zellengröße',
|
||||
arrange: 'Anordnen',
|
||||
shapeStyles: 'Formenarten',
|
||||
editing: 'Bearbeiten',
|
||||
},
|
||||
case: {
|
||||
@@ -524,6 +531,7 @@ const de: Messages = {
|
||||
tocOptions: 'Ebenen',
|
||||
tocMaxLevel: 'Tiefste angezeigte Ebene',
|
||||
debugDump: 'Dump',
|
||||
cellMargins: 'Zellenränder',
|
||||
chrome: {
|
||||
title: 'Symbolleisten-Stil',
|
||||
ribbon: 'Menüband',
|
||||
|
||||
@@ -480,6 +480,13 @@ const en = {
|
||||
language: 'Language',
|
||||
show: 'Show',
|
||||
debug: 'Debug',
|
||||
tableDecor: 'Shading & Borders',
|
||||
tableOptions: 'Table Style Options',
|
||||
rowsColumns: 'Rows & Columns',
|
||||
merge: 'Merge',
|
||||
cellSize: 'Cell Size',
|
||||
arrange: 'Arrange',
|
||||
shapeStyles: 'Shape Styles',
|
||||
editing: 'Editing',
|
||||
},
|
||||
case: {
|
||||
@@ -528,6 +535,7 @@ const en = {
|
||||
tocOptions: 'Levels',
|
||||
tocMaxLevel: 'Deepest level shown',
|
||||
debugDump: 'Dump',
|
||||
cellMargins: 'Cell margins',
|
||||
chrome: {
|
||||
title: 'Toolbar style',
|
||||
ribbon: 'Ribbon',
|
||||
|
||||
Reference in new issue
Block a user