feat: add character style editor
This commit is contained in:
1 parent
3dcae6f640
commit
ec0cfb76fd
8 files changed
+213
-43
No files matched your search
+11
-5
@@ -27,7 +27,15 @@
|
||||
- Font family picker (lists only fonts installed on the machine; remembers recently used fonts)
|
||||
- Font size
|
||||
- Font color and highlight color (color picker with custom colors)
|
||||
- Headings H1–H3, including un-bolding a heading
|
||||
- Headings H1–H5, including un-bolding a heading
|
||||
|
||||
**Styles**
|
||||
- Named paragraph styles with LibreOffice's inheritance model: a style has a parent, a follow-on style and its own properties; changing a style updates every paragraph using it. Built-ins Default text → Heading → Heading 1–5 / Title / Subtitle / Quote, with LibreOffice's values (18/16/14/13/12 pt headings)
|
||||
- Named character styles (Emphasis / Strong Emphasis / Source Text + own ones) as a mark on a text run
|
||||
- Style gallery in the toolbar lists both families; assigning a style keeps hard formatting (Word/LibreOffice behavior), Ctrl+M clears direct formatting but keeps the style
|
||||
- Style manager ("Manage styles…"): inheritance tree, live property editing (empty field = inherit again), new / update from selection, rename, delete, reset a built-in — for paragraph and character styles alike
|
||||
- Round-trips as real styles, not baked formatting: ODF `style:style` (`style:family="paragraph"`/`"text"`, `style:parent-style-name`, `style:next-style-name`) and DOCX `w:pStyle`/`w:rStyle` with `w:basedOn`/`w:next`; import adopts the file's used styles plus their parent chains, so only formatting beyond the style stays direct
|
||||
- A new document starts from the built-in styles, like Word's/LibreOffice's default template
|
||||
|
||||
**Paragraphs & lists**
|
||||
- Text alignment: left, center, right, justify
|
||||
@@ -45,7 +53,7 @@
|
||||
- Images: inline or floating with text wrap (left / right / top-bottom), resize handles, rotation, live size badge; insert via toolbar, drag-and-drop, or paste
|
||||
- Special characters picker
|
||||
- Date and time fields: picker with 7 date and 4 time formats (live samples) and an "update automatically" toggle — fixed fields keep the inserted moment, auto fields refresh on open. Round-trips to ODF `text:date`/`text:time` (minted `number:date/time-style`) and DOCX `DATE`/`TIME` fields; the field carries the surrounding font
|
||||
- Table of contents: generated from headings (H1–H3) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
|
||||
- Table of contents: generated from headings (H1–H5) with live page numbers and dot leaders, click an entry to jump to its heading; round-trips to ODF `text:table-of-content` and a Word TOC field
|
||||
- Hyperlinks: create / edit / remove (toolbar + Ctrl+K), Ctrl/Cmd+click to open, hover hint showing the URL; ODF `text:a` round-trip
|
||||
- Manual page break (Ctrl+Enter); round-trips to ODF `fo:break-before`
|
||||
- Text boxes and basic shapes (rectangle / rounded rectangle / ellipse): editable block content, fill and border colors, border width, resize/rotate handles, text wrap (inline / left / right / top-bottom) like images; floating toolbar for wrap, shape kind and colors. Round-trips to ODF `draw:frame`/`draw:text-box` + `draw:custom-shape` and DOCX DrawingML `wps:wsp`/`wps:txbx` (imports Word's `mc:AlternateContent` and legacy VML text boxes too)
|
||||
@@ -85,17 +93,15 @@
|
||||
|
||||
Planned word-processor features (to match Word/LibreOffice):
|
||||
- Footnotes / endnotes (currently dropped on import)
|
||||
- Clear formatting (remove all marks)
|
||||
- Comments / annotations (currently dropped on import)
|
||||
- Horizontal rule
|
||||
- Page numbering options (start value / format) + different first-page header
|
||||
- Heading levels H4–H6 (currently clamped to H3)
|
||||
- Heading level H6 (currently clamped to H5)
|
||||
- Paragraph borders / shading
|
||||
- Track changes (revisions)
|
||||
- Lines / arrows / connectors / freeform and other shape presets (currently dropped on import with a warning; text boxes + rect/round-rect/ellipse are supported)
|
||||
- Equations / formulas
|
||||
- Cross-references / bookmarks
|
||||
- Named paragraph / character styles (style gallery)
|
||||
|
||||
Other:
|
||||
- Custom right-click context menu (formatting, cut/copy)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { loadOrientation, saveOrientation, type Orientation } from './lib/storage/pageOrientation';
|
||||
import { loadPageFormat, savePageFormat, type PageFormat } from './lib/storage/pageFormat';
|
||||
import { setStyleSheet, styleSheet } from './lib/styles/sheet.svelte';
|
||||
import { builtinStyleSheet } from './lib/styles/styleSheet';
|
||||
import { loadHfDoc, saveHfDoc, loadHfDistances, saveHfDistances, loadDifferentFirstPage, saveDifferentFirstPage, loadDifferentOddEven, saveDifferentOddEven, hfIsEmpty, DEFAULT_HF_DISTANCES, type HfDoc, type HfZone, type HfDistances } from './lib/storage/headerFooter';
|
||||
import { loadDocName, saveDocName, stripOdtExtension, sanitizeNameForFile } from './lib/storage/documentName';
|
||||
import { loadDocumentLanguage, saveDocumentLanguage, odfFromLanguage, type DocumentLanguage } from './lib/storage/documentLanguage';
|
||||
@@ -391,6 +392,8 @@
|
||||
hfDistances = { ...DEFAULT_HF_DISTANCES };
|
||||
documentName = '';
|
||||
fileHandle = null;
|
||||
// Styles live in the document, so a new one starts from the built-ins
|
||||
setStyleSheet(builtinStyleSheet());
|
||||
clearEmbeddedFonts();
|
||||
void clearEmbeddedFontStore();
|
||||
editor.commands.focus();
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import {
|
||||
DEFAULT_STYLE, HEADING_PARENT, isAbstractStyle, propsFromBlock, resolveStyle, styleDelta,
|
||||
styleOrder, uniqueStyleName, type Style,
|
||||
styleOrder, uniqueStyleName, type Style, type StyleFamily,
|
||||
} from '../styles/styleSheet';
|
||||
import { deleteStyle, putStyle, renameStyle, resetStyle, styleSheet } from '../styles/sheet.svelte';
|
||||
import { deleteCharacterStyle, deleteStyle, putStyle, renameStyle, resetStyle, styleSheet } from '../styles/sheet.svelte';
|
||||
import { blockStyleName } from '../editor/extensions/paragraphStyle';
|
||||
import { activeCharacterStyle } from '../editor/extensions/characterStyle';
|
||||
import { CANDIDATE_FONTS, detectAvailableFonts } from '../utils/fontDetect';
|
||||
import AlignIcon, { type AlignValue } from './AlignIcon.svelte';
|
||||
|
||||
@@ -17,26 +18,40 @@
|
||||
const ALIGNMENTS: AlignValue[] = ['left', 'center', 'right', 'justify'];
|
||||
|
||||
let dialogEl: HTMLDialogElement | null = $state(null);
|
||||
let family = $state<StyleFamily>('paragraph');
|
||||
let selected = $state(DEFAULT_STYLE);
|
||||
let selectedChar = $state('');
|
||||
// The fonts installed on this machine, detected once the dialog is first opened
|
||||
// (same list the font picker uses).
|
||||
let fonts = $state<string[]>([]);
|
||||
|
||||
let sheet = $derived(styleSheet());
|
||||
let isChar = $derived(family === 'character');
|
||||
let styles = $derived(isChar ? sheet.character : sheet.paragraph);
|
||||
// Indented by inheritance depth, so the chain is visible.
|
||||
let rows = $derived(styleOrder(sheet, true).map((s) => ({ style: s, depth: depthOf(s) })));
|
||||
let style = $derived(sheet.paragraph[selected] ?? sheet.paragraph[DEFAULT_STYLE]);
|
||||
let resolved = $derived(resolveStyle(sheet, selected));
|
||||
let rows = $derived(
|
||||
isChar
|
||||
? Object.values(sheet.character).map((s) => ({ style: s, depth: depthOf(s) }))
|
||||
: styleOrder(sheet, true).map((s) => ({ style: s, depth: depthOf(s) })),
|
||||
);
|
||||
let current = $derived(isChar ? selectedChar : selected);
|
||||
let style = $derived(styles[current] ?? Object.values(styles)[0]);
|
||||
let resolved = $derived(resolveStyle(sheet, style?.name, family));
|
||||
// A style can't inherit from itself or from one of its own descendants.
|
||||
let parentOptions = $derived(
|
||||
Object.keys(sheet.paragraph).filter((name) => name !== style.name && !descendsFrom(name, style.name)),
|
||||
Object.keys(styles).filter((name) => name !== style?.name && !descendsFrom(name, style?.name ?? '')),
|
||||
);
|
||||
|
||||
function select(name: string) {
|
||||
if (isChar) selectedChar = name;
|
||||
else selected = name;
|
||||
}
|
||||
|
||||
function depthOf(s: Style): number {
|
||||
let depth = 0;
|
||||
let cur = s.parent;
|
||||
const seen = new Set<string>();
|
||||
while (cur && sheet.paragraph[cur] && !seen.has(cur)) { seen.add(cur); depth++; cur = sheet.paragraph[cur].parent; }
|
||||
while (cur && styles[cur] && !seen.has(cur)) { seen.add(cur); depth++; cur = styles[cur].parent; }
|
||||
return depth;
|
||||
}
|
||||
|
||||
@@ -46,7 +61,7 @@
|
||||
while (cur && !seen.has(cur)) {
|
||||
seen.add(cur);
|
||||
if (cur === ancestor) return true;
|
||||
cur = sheet.paragraph[cur]?.parent ?? null;
|
||||
cur = styles[cur]?.parent ?? null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -64,7 +79,9 @@
|
||||
|
||||
// Follow the cursor's style when the dialog opens (LibreOffice preselects it too).
|
||||
$effect(() => {
|
||||
if (open && editor) selected = blockStyleName(editor.state.selection.$from.parent as never);
|
||||
if (!open || !editor) return;
|
||||
selected = blockStyleName(editor.state.selection.$from.parent as never);
|
||||
selectedChar = activeCharacterStyle(editor.state as never) ?? Object.keys(sheet.character)[0] ?? '';
|
||||
});
|
||||
|
||||
// An undefined patch value clears the style's own property, so it inherits again.
|
||||
@@ -77,7 +94,7 @@
|
||||
}
|
||||
return out;
|
||||
};
|
||||
putStyle({ ...style, para: merge(style.para, patch.para ?? {}), text: merge(style.text, patch.text ?? {}) });
|
||||
putStyle({ ...style, para: merge(style.para, patch.para ?? {}), text: merge(style.text, patch.text ?? {}) }, family);
|
||||
}
|
||||
|
||||
// A style may name a font that isn't installed here; keep it in the list so editing
|
||||
@@ -108,9 +125,43 @@
|
||||
};
|
||||
}
|
||||
|
||||
// The marks of the selection's first run — the raw material for a character style.
|
||||
function runFormatting() {
|
||||
if (!editor) return null;
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
const marks = empty
|
||||
? editor.state.storedMarks ?? editor.state.selection.$head.marks()
|
||||
: (editor.state.doc.nodeAt(from) ?? editor.state.doc.nodeAt(Math.max(0, to - 1)))?.marks ?? [];
|
||||
return propsFromBlock({ attrs: {} }, marks as never).text;
|
||||
}
|
||||
|
||||
// Drop the direct marks the new style now carries, then apply it — as in LibreOffice.
|
||||
function applyCharStyle(name: string, text: ReturnType<typeof runFormatting>) {
|
||||
if (!editor || !text) return;
|
||||
const chain = editor.chain().focus();
|
||||
if (text.bold) chain.unsetBold();
|
||||
if (text.italic) chain.unsetItalic();
|
||||
if (text.underline) chain.unsetUnderline();
|
||||
if (text.strike) chain.unsetStrike();
|
||||
if (text.fontFamily) chain.unsetFontFamily();
|
||||
if (text.fontSizePt != null) chain.unsetFontSize();
|
||||
if (text.color) chain.unsetColor();
|
||||
chain.removeEmptyTextStyle().setCharacterStyle(name).run();
|
||||
}
|
||||
|
||||
function newFromSelection() {
|
||||
if (!editor) return;
|
||||
if (isChar) {
|
||||
const text = runFormatting();
|
||||
if (!text || !Object.keys(text).length) return;
|
||||
const name = uniqueStyleName(sheet, t().styles.newName, 'character');
|
||||
putStyle({ name, parent: null, next: null, para: {}, text }, 'character');
|
||||
applyCharStyle(name, text);
|
||||
selectedChar = name;
|
||||
return;
|
||||
}
|
||||
const info = blockFormatting();
|
||||
if (!info || !editor) return;
|
||||
if (!info) return;
|
||||
const name = uniqueStyleName(sheet, t().styles.newName);
|
||||
const own = styleDelta(info.shown, resolveStyle(sheet, info.current));
|
||||
putStyle({ name, parent: info.current, next: info.current, para: own.para, text: own.text });
|
||||
@@ -121,41 +172,68 @@
|
||||
}
|
||||
|
||||
function updateFromSelection() {
|
||||
if (!editor || !style) return;
|
||||
if (isChar) {
|
||||
const text = runFormatting();
|
||||
if (!text) return;
|
||||
putStyle({ ...style, text: { ...style.text, ...text } }, 'character');
|
||||
applyCharStyle(style.name, text);
|
||||
return;
|
||||
}
|
||||
const info = blockFormatting();
|
||||
if (!info || !editor) return;
|
||||
if (!info) return;
|
||||
const own = styleDelta(info.shown, resolveStyle(sheet, style.parent ?? DEFAULT_STYLE));
|
||||
putStyle({ ...style, para: { ...style.para, ...own.para }, text: { ...style.text, ...own.text } });
|
||||
editor.commands.setParagraphStyle(style.name);
|
||||
editor.commands.clearDirectFormatting();
|
||||
}
|
||||
|
||||
// A character style with nothing selected yet: start from the built-in defaults.
|
||||
function newEmptyStyle() {
|
||||
const name = uniqueStyleName(sheet, t().styles.newName, 'character');
|
||||
putStyle({ name, parent: null, next: null, para: {}, text: {} }, 'character');
|
||||
selectedChar = name;
|
||||
}
|
||||
|
||||
function rename() {
|
||||
const next = prompt(t().styles.renamePrompt, style.name)?.trim();
|
||||
if (!next || next === style.name || sheet.paragraph[next]) return;
|
||||
if (!next || next === style.name || styles[next]) return;
|
||||
const from = style.name;
|
||||
renameStyle(from, next);
|
||||
renameStyle(from, next, family);
|
||||
// Retag every block that referenced the old name.
|
||||
retag(from, next);
|
||||
select(next);
|
||||
}
|
||||
|
||||
// Re-point every block (or run) that referenced `from`; null drops the reference.
|
||||
function retag(from: string, to: string | null) {
|
||||
editor?.commands.command(({ tr, state, dispatch }) => {
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.attrs?.styleName === from) tr.setNodeAttribute(pos, 'styleName', next);
|
||||
if (isChar) {
|
||||
const mark = node.marks.find((m) => m.type.name === 'charStyle' && m.attrs.name === from);
|
||||
if (!mark) return;
|
||||
tr.removeMark(pos, pos + node.nodeSize, mark);
|
||||
if (to) tr.addMark(pos, pos + node.nodeSize, mark.type.create({ name: to }));
|
||||
} else if (node.attrs?.styleName === from && to) {
|
||||
tr.setNodeAttribute(pos, 'styleName', to);
|
||||
}
|
||||
});
|
||||
if (dispatch && tr.docChanged) dispatch(tr);
|
||||
return true;
|
||||
});
|
||||
selected = next;
|
||||
}
|
||||
|
||||
function remove() {
|
||||
const gone = style.name;
|
||||
if (isChar) {
|
||||
deleteCharacterStyle(gone);
|
||||
retag(gone, null);
|
||||
selectedChar = Object.keys(sheet.character)[0] ?? '';
|
||||
return;
|
||||
}
|
||||
const fallback = style.parent ?? DEFAULT_STYLE;
|
||||
deleteStyle(gone);
|
||||
editor?.commands.command(({ tr, state, dispatch }) => {
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.attrs?.styleName === gone) tr.setNodeAttribute(pos, 'styleName', fallback);
|
||||
});
|
||||
if (dispatch && tr.docChanged) dispatch(tr);
|
||||
return true;
|
||||
});
|
||||
retag(gone, fallback);
|
||||
selected = fallback;
|
||||
}
|
||||
</script>
|
||||
@@ -178,14 +256,19 @@
|
||||
</header>
|
||||
|
||||
<div class="body">
|
||||
<div class="pane">
|
||||
<div class="family">
|
||||
<button class:active={!isChar} onclick={() => (family = 'paragraph')}>{t().styles.paragraphStyles}</button>
|
||||
<button class:active={isChar} onclick={() => (family = 'character')}>{t().styles.characterStyles}</button>
|
||||
</div>
|
||||
<ul class="list">
|
||||
{#each rows as { style: s, depth } (s.name)}
|
||||
<li>
|
||||
<button
|
||||
class="entry"
|
||||
class:active={selected === s.name}
|
||||
class:active={current === s.name}
|
||||
style="padding-left: {0.6 + depth * 0.8}rem"
|
||||
onclick={() => (selected = s.name)}
|
||||
onclick={() => select(s.name)}
|
||||
>
|
||||
{s.name}
|
||||
{#if isAbstractStyle(s.name)}<span class="badge">{t().styles.abstract}</span>
|
||||
@@ -194,6 +277,7 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="fields">
|
||||
<!-- Fallbacks are the page's own defaults, not `inherit`: an inline `inherit`
|
||||
@@ -209,7 +293,7 @@
|
||||
">{style.name}</div>
|
||||
|
||||
<label>{t().styles.parent}
|
||||
<select value={style.parent ?? ''} onchange={(e) => putStyle({ ...style, parent: e.currentTarget.value || null })}>
|
||||
<select value={style.parent ?? ''} onchange={(e) => putStyle({ ...style, parent: e.currentTarget.value || null }, family)}>
|
||||
<option value="">—</option>
|
||||
{#each parentOptions as name}<option value={name}>{name}</option>{/each}
|
||||
</select>
|
||||
@@ -251,6 +335,7 @@
|
||||
onchange={(e) => edit({ text: { strike: e.currentTarget.checked } })} />{t().styles.strike}</label>
|
||||
</div>
|
||||
|
||||
{#if !isChar}
|
||||
<div class="field">
|
||||
<span class="field-label">{t().styles.alignment}</span>
|
||||
<div class="align-row">
|
||||
@@ -286,15 +371,17 @@
|
||||
onchange={(e) => edit({ para: { indent: num(e.currentTarget.value) } })} />
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
{#if isChar}<button onclick={newEmptyStyle}>{t().styles.newStyle}</button>{/if}
|
||||
<button onclick={newFromSelection}>{t().styles.newFromSelection}</button>
|
||||
<button onclick={updateFromSelection}>{t().styles.updateFromSelection}</button>
|
||||
<span class="spacer"></span>
|
||||
{#if style.builtin}
|
||||
<button onclick={() => resetStyle(style.name)}>{t().styles.reset}</button>
|
||||
<button onclick={() => resetStyle(style.name, family)}>{t().styles.reset}</button>
|
||||
{:else}
|
||||
<button onclick={rename}>{t().styles.rename}</button>
|
||||
<button class="danger" onclick={remove} disabled={style.name === HEADING_PARENT}>{t().common.remove}</button>
|
||||
@@ -349,13 +436,39 @@
|
||||
|
||||
.body { display: flex; gap: 0; max-height: min(30rem, 70vh); }
|
||||
|
||||
.list {
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 14rem;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
.family {
|
||||
display: flex;
|
||||
padding: 0.4rem 0.4rem 0;
|
||||
gap: 2px;
|
||||
}
|
||||
.family button {
|
||||
flex: 1;
|
||||
padding: 0.3rem 0.2rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font: inherit;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.family button.active {
|
||||
background: var(--color-btn-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.4rem 0;
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
.entry {
|
||||
display: flex;
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{#if stylesOpen}
|
||||
<div class="ol-dropdown" role="menu">
|
||||
<div class="ol-dropdown style-dropdown" role="menu">
|
||||
<div class="ol-section-label">{t().toolbar.styles.title}</div>
|
||||
{#each galleryStyles as s}
|
||||
{@const preview = resolveStyle(sheet, s.name)}
|
||||
@@ -243,6 +243,12 @@
|
||||
{/each}
|
||||
{/if}
|
||||
<button class="ol-option manage" onclick={() => { stylesOpen = false; managerOpen = true; }}>
|
||||
<!-- Sliders, as on the Tools button: this opens a settings surface -->
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M2 4.5h8M13 4.5h1M2 11.5h1M6 11.5h8" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
|
||||
<circle cx="11.5" cy="4.5" r="1.6" stroke="currentColor" stroke-width="1.4"/>
|
||||
<circle cx="4.5" cy="11.5" r="1.6" stroke="currentColor" stroke-width="1.4"/>
|
||||
</svg>
|
||||
{t().styles.manage}
|
||||
</button>
|
||||
</div>
|
||||
@@ -468,17 +474,41 @@
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* The gallery lists every registered style, so it scrolls inside itself like the
|
||||
font picker instead of growing past the viewport. */
|
||||
.style-dropdown {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.char-label {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Pinned to the scrolling gallery's bottom edge (bottom cancels the dropdown's
|
||||
padding) so it is reachable without scrolling to the last style. */
|
||||
.manage {
|
||||
position: sticky;
|
||||
bottom: -2px;
|
||||
margin-top: 2px;
|
||||
padding: 0.45rem 0.6rem calc(0.45rem + 2px);
|
||||
gap: 0.45rem;
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-radius: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
/* Mixed toward the text color so it stays legible on the dark surfaces too. */
|
||||
color: color-mix(in srgb, var(--color-primary) 55%, var(--color-text));
|
||||
}
|
||||
|
||||
.manage svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.manage:hover {
|
||||
background: color-mix(in srgb, var(--color-primary) 16%, var(--color-surface));
|
||||
}
|
||||
|
||||
/* Ordered-list split button: main toggle + chevron that opens the numbering
|
||||
|
||||
@@ -69,9 +69,11 @@ const de: Messages = {
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
title: 'Absatzvorlagen',
|
||||
title: 'Formatvorlagen',
|
||||
manage: 'Vorlagen verwalten…',
|
||||
characterStyles: 'Zeichenvorlagen',
|
||||
paragraphStyles: 'Absatzvorlagen',
|
||||
newStyle: 'Neu',
|
||||
custom: 'eigen',
|
||||
inherited: 'geerbt',
|
||||
abstract: 'nur Elternstil',
|
||||
|
||||
@@ -73,9 +73,11 @@ const en = {
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
title: 'Paragraph styles',
|
||||
title: 'Styles',
|
||||
manage: 'Manage styles…',
|
||||
characterStyles: 'Character styles',
|
||||
paragraphStyles: 'Paragraph styles',
|
||||
newStyle: 'New',
|
||||
custom: 'own',
|
||||
inherited: 'inherited',
|
||||
abstract: 'parent only',
|
||||
|
||||
@@ -35,7 +35,8 @@ export function putStyle(style: Style, family: StyleFamily = 'paragraph'): void
|
||||
|
||||
// Rename a style and re-point everything that referenced it (children, next-styles).
|
||||
// Blocks keep their own `styleName`; the caller retags them.
|
||||
export function renameStyle(from: string, to: string): void {
|
||||
export function renameStyle(from: string, to: string, family: StyleFamily = 'paragraph'): void {
|
||||
if (family === 'character') return renameCharacterStyle(from, to);
|
||||
const style = current.paragraph[from];
|
||||
if (!style || from === to || current.paragraph[to]) return;
|
||||
const paragraph: Record<string, Style> = {};
|
||||
@@ -77,3 +78,15 @@ export function deleteCharacterStyle(name: string): void {
|
||||
delete character[name];
|
||||
setStyleSheet({ ...current, character });
|
||||
}
|
||||
|
||||
function renameCharacterStyle(from: string, to: string): void {
|
||||
const style = current.character[from];
|
||||
if (!style || from === to || current.character[to]) return;
|
||||
const character: Record<string, Style> = {};
|
||||
for (const [name, s] of Object.entries(current.character)) {
|
||||
if (name === from) continue;
|
||||
character[name] = { ...s, parent: s.parent === from ? to : s.parent };
|
||||
}
|
||||
character[to] = { ...style, name: to, builtin: undefined };
|
||||
setStyleSheet({ ...current, character });
|
||||
}
|
||||
@@ -271,7 +271,8 @@ export function styleDelta(shown: ResolvedStyle, base: ResolvedStyle): ResolvedS
|
||||
}
|
||||
|
||||
// A name that isn't taken yet ("Style", "Style 2", …).
|
||||
export function uniqueStyleName(sheet: StyleSheet, base: string): string {
|
||||
if (!sheet.paragraph[base]) return base;
|
||||
for (let i = 2; ; i++) if (!sheet.paragraph[`${base} ${i}`]) return `${base} ${i}`;
|
||||
export function uniqueStyleName(sheet: StyleSheet, base: string, family: StyleFamily = 'paragraph'): string {
|
||||
const styles = family === 'character' ? sheet.character : sheet.paragraph;
|
||||
if (!styles[base]) return base;
|
||||
for (let i = 2; ; i++) if (!styles[`${base} ${i}`]) return `${base} ${i}`;
|
||||
}
|
||||
Reference in new issue
Block a user