diff --git a/crates/sublore-edit/src/plan.rs b/crates/sublore-edit/src/plan.rs index 137ec1b..d607b4e 100644 --- a/crates/sublore-edit/src/plan.rs +++ b/crates/sublore-edit/src/plan.rs @@ -6,6 +6,7 @@ //! edit, and `verify` holds the parse to what the plan predicted. Nothing here reaches into //! `sublore-formats`: the parsers stay the only authority on grammar. +use sublore_formats::override_tags::{self, StyleFlag}; use sublore_formats::{ AssEvent, AssEventKind, AssField, Cue, CueDetail, Newline, Segment, SegmentKind, Span, SrtCue, SubtitleDocument, SubtitleFormat, MAX_TIMECODE_MS, @@ -48,6 +49,17 @@ pub enum Edit { field: AssField, value: String, }, + /// Turn one of the four inline style flags on or off over a stretch of a cue's text. `from` and + /// `to` are byte offsets into the text as the file spells it, braces included, which is what + /// the panel's own box shows and therefore what its caret reports. Equal offsets are a caret + /// rather than a selection, and the tag then takes effect to the end of the line. + /// See edit-bar-tasks.md B11. + ToggleStyle { + cue: usize, + flag: StyleFlag, + from: usize, + to: usize, + }, /// Turn an ASS event into a `Comment:` or back into a `Dialogue:`. The descriptor is not one /// of the fields `AssField` can name, and this changes how many cues a player would draw, so it /// is its own edit. See edit-bar-tasks.md B8. @@ -130,6 +142,12 @@ pub fn plan(document: &SubtitleDocument, edit: &Edit) -> Result plan_set_times(document, *cue, *start_ms, *end_ms), Edit::SetField { cue, field, value } => plan_set_field(document, *cue, *field, value), Edit::SetComment { cue, comment } => plan_set_comment(document, *cue, *comment), + Edit::ToggleStyle { + cue, + flag, + from, + to, + } => plan_toggle_style(document, *cue, *flag, *from, *to), Edit::Insert { before, start_ms, @@ -1084,6 +1102,91 @@ fn validate_field_value(field: AssField, value: &str) -> Result<(), EditError> { /// cannot reach one whether the field is first, last before the text, or in between. /// A splice over the event's own descriptor, which is the word before the colon. Nothing else on /// the line moves, so the times and the text the verifier checks are the ones that were there. +/// The flag's state at the caret, then the opposite of it written there, and the state it had put +/// back at the far end of the selection shifted by whatever the first write inserted. That is the +/// whole of it, and it is why the writer returns a shift. +fn plan_toggle_style( + document: &SubtitleDocument, + index: usize, + flag: StyleFlag, + from: usize, + to: usize, +) -> Result { + let located = locate(document, index)?; + let CueDetail::Ass(event) = &located.cue.detail else { + return Err(EditError::new( + EditErrorKind::NotApplicable, + "only an ASS event carries override tags", + )); + }; + let text = document.slice(located.cue.text); + if from > text.len() + || to > text.len() + || !text.is_char_boundary(from) + || !text.is_char_boundary(to) + { + return Err(EditError::new( + EditErrorKind::NotApplicable, + format!( + "the {} range {from}..{to} is outside the cue's text or cuts a character", + flag.as_str() + ), + )); + } + let (start, end) = if from <= to { (from, to) } else { (to, from) }; + + // Where the line starts from: the style it names, and then any tag of its own before the caret. + let named = event + .field_index(AssField::Style) + .and_then(|at| event.fields.get(at).copied()) + .map(|span| document.slice(field_core(document, span))) + .unwrap_or(""); + let from_style = document + .ass_styles() + .iter() + .find(|style| document.ass_style_text(style)[0] == named) + .is_some_and(|style| flag.of(style)); + let state = override_tags::block_at(text, start) + .and_then(|block| override_tags::value_at(text, block, flag.tag())) + .map_or(from_style, |value| { + override_tags::flag_value(&value, from_style) + }); + + let (written, shift) = + override_tags::set_tag(text, start, flag.tag(), if state { "0" } else { "1" }); + let written = if start == end { + written + } else { + let at = end.saturating_add_signed(shift); + override_tags::set_tag(&written, at, flag.tag(), if state { "1" } else { "0" }).0 + }; + + let write = plan_text_write(document, &located, &written)?; + Ok(Planned { + splice: Splice::new( + write.range.start, + document.slice(write.range).to_owned(), + write.inserted, + ), + label: EditLabel { + kind: EditKind::ToggleStyle(flag), + cue: index, + }, + expect: Expectation { + from: index, + removed: 1, + cues: vec![ExpectedCue { + text_raw: write.written, + start_ms: located.cue.start.millis(), + end_ms: located.cue.end.millis(), + }], + segments_from: located.segment_index, + segments_removed: 1, + segments_inserted: 1, + }, + }) +} + fn plan_set_comment( document: &SubtitleDocument, index: usize, diff --git a/crates/sublore-edit/src/splice.rs b/crates/sublore-edit/src/splice.rs index 9d8622d..d818af6 100644 --- a/crates/sublore-edit/src/splice.rs +++ b/crates/sublore-edit/src/splice.rs @@ -66,6 +66,9 @@ pub enum EditKind { /// Its own kind, never `SetField`: the descriptor is not one of the fields the `Format:` line /// declares, and turning a line into a comment changes how many cues a player would draw. SetComment, + /// Which flag is on the label, for the reason `SetField` carries its field: bold and italic on + /// one line must never merge into one undo step. + ToggleStyle(sublore_formats::override_tags::StyleFlag), Insert, Delete, Split, diff --git a/crates/sublore-edit/tests/session.rs b/crates/sublore-edit/tests/session.rs index df9db17..4b21e9d 100644 --- a/crates/sublore-edit/tests/session.rs +++ b/crates/sublore-edit/tests/session.rs @@ -14,6 +14,7 @@ use sublore_edit::error::EditErrorKind; use sublore_edit::history::Run; use sublore_edit::plan::Edit; use sublore_edit::session::EditSession; +use sublore_formats::override_tags::StyleFlag; use sublore_formats::{AssField, SubtitleDocument, SubtitleFormat}; /// A typing pause, well inside `history::COALESCE_WINDOW`. @@ -985,6 +986,96 @@ fn a_field_committed_as_whitespace_writes_nothing_however_often_it_is_committed( ); } +fn toggle_style(cue: usize, flag: StyleFlag, from: usize, to: usize) -> Edit { + Edit::ToggleStyle { + cue, + flag, + from, + to, + } +} + +/// The text of one cue as the file spells it, braces included. +fn raw_text(session: &EditSession, cue: usize) -> String { + let document = session.document(); + let found = document.cues().nth(cue).expect("the cue is there"); + document.slice(found.text).to_owned() +} + +#[test] +fn a_style_toggle_over_a_selection_wraps_it_and_leaves_the_rest_alone() { + // B11: the flag is off in the style, so the selection is turned on and turned back off at its + // far end, which is what the writer's shift is for. + let mut session = session("ass/clean/basic.ass"); + let text = raw_text(&session, 0); + let at = text + .find("harbour") + .expect("the fixture's first line holds it"); + session + .apply( + &toggle_style(0, StyleFlag::Bold, at, at + 7), + Run::New, + Instant::now(), + ) + .expect("an ASS event takes an override tag"); + assert_eq!( + raw_text(&session, 0), + format!("{}{{\\b1}}harbour{{\\b0}}{}", &text[..at], &text[at + 7..]) + ); + + session.undo().expect("a step to undo").expect("a patch"); + assert_eq!(raw_text(&session, 0), text, "one undo puts the line back"); +} + +#[test] +fn a_style_toggle_at_a_caret_writes_one_tag_and_no_closing_one() { + let mut session = session("ass/clean/basic.ass"); + let text = raw_text(&session, 0); + session + .apply( + &toggle_style(0, StyleFlag::Italic, 0, 0), + Run::New, + Instant::now(), + ) + .expect("an ASS event takes an override tag"); + assert_eq!(raw_text(&session, 0), format!("{{\\i1}}{text}")); +} + +#[test] +fn a_second_toggle_of_the_same_flag_turns_it_off_again() { + let mut session = session("ass/clean/basic.ass"); + let text = raw_text(&session, 0); + let now = Instant::now(); + session + .apply(&toggle_style(0, StyleFlag::Bold, 0, 0), Run::New, now) + .expect("the first toggle turns it on"); + assert_eq!(raw_text(&session, 0), format!("{{\\b1}}{text}")); + session + .apply( + &toggle_style(0, StyleFlag::Bold, 5, 5), + Run::New, + now + APART, + ) + .expect("the second reads the tag already there"); + // The caret is inside the block the first write made, so the tag is replaced where it stood + // rather than a second one being added. + assert_eq!(raw_text(&session, 0), format!("{{\\b0}}{text}")); +} + +#[test] +fn a_style_toggle_is_refused_outside_the_cue_and_writes_nothing() { + let mut session = session("ass/clean/basic.ass"); + let before = session.to_bytes(); + session + .apply( + &toggle_style(0, StyleFlag::Bold, 0, 9999), + Run::New, + Instant::now(), + ) + .expect_err("a range past the end of the text is refused"); + assert_eq!(session.to_bytes(), before, "a refusal writes nothing"); +} + fn set_comment(cue: usize, comment: bool) -> Edit { Edit::SetComment { cue, comment } } diff --git a/crates/sublore-formats/src/override_tags.rs b/crates/sublore-formats/src/override_tags.rs index 004ec9e..cc9ac5e 100644 --- a/crates/sublore-formats/src/override_tags.rs +++ b/crates/sublore-formats/src/override_tags.rs @@ -104,6 +104,58 @@ pub fn blocks(text: &str) -> Vec { out } +/// One of the four flags a line can be styled with, inline. Closed on purpose: these four are the +/// ones the panel draws, and each is a boolean the style starts and an override tag may change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StyleFlag { + Bold, + Italic, + Underline, + Strikeout, +} + +impl StyleFlag { + /// The tag that carries it, backslash included. + pub fn tag(self) -> &'static str { + match self { + StyleFlag::Bold => "\\b", + StyleFlag::Italic => "\\i", + StyleFlag::Underline => "\\u", + StyleFlag::Strikeout => "\\s", + } + } + + /// Its name, for a refusal that has to say which flag it is about. + pub fn as_str(self) -> &'static str { + match self { + StyleFlag::Bold => "bold", + StyleFlag::Italic => "italic", + StyleFlag::Underline => "underline", + StyleFlag::Strikeout => "strikeout", + } + } + + /// What a style sets it to, which is where a line starts before any tag of its own. + pub fn of(self, style: &crate::document::AssStyle) -> bool { + match self { + StyleFlag::Bold => style.bold, + StyleFlag::Italic => style.italic, + StyleFlag::Underline => style.underline, + StyleFlag::Strikeout => style.strikeout, + } + } +} + +/// Whether a tag's value reads as on. ASS writes `1` for on and `0` for off inside a line, unlike +/// the styles section, which writes `-1`; anything that is not a number leaves the state alone, +/// which is what falling back to `initial` means here. +pub fn flag_value(value: &str, initial: bool) -> bool { + value + .trim() + .parse::() + .map_or(initial, |number| number != 0) +} + /// One tag inside an override block: the name with its backslash, and the value that follows it. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Tag { @@ -273,14 +325,21 @@ pub fn set_tag(text: &str, at: usize, name: &str, value: &str) -> (String, isize /// that brace opens. Raw rather than visible-only because the panel's box shows the line as the /// file spells it, braces included, so the caret it reports is already an offset into this text. fn block_at_raw(parsed: &[Block], at: usize) -> Option { + let braced = |block: &Block| matches!(block.kind, BlockKind::Override | BlockKind::Comment); for (index, block) in parsed.iter().enumerate() { if at < block.span.end { return Some(index); } if at == block.span.end { + // On a boundary, the braced side wins. Just past a closing brace the tags of the block + // that closed are the ones in force; just before an opening one, the block it opens is + // where a hand means the tag to go. + if braced(block) { + return Some(index); + } return Some(match parsed.get(index + 1) { - Some(_) => index + 1, - None => index, + Some(next) if braced(next) => index + 1, + _ => index, }); } } diff --git a/e2e/specs/command-registry.spec.js b/e2e/specs/command-registry.spec.js index d646120..3721d5c 100644 --- a/e2e/specs/command-registry.spec.js +++ b/e2e/specs/command-registry.spec.js @@ -47,6 +47,10 @@ const DECLARED = [ "asr-transcribe", "edit-undo", "edit-redo", + "edit-style-bold", + "edit-style-italic", + "edit-style-underline", + "edit-style-strikeout", "edit-find", "edit-find-next", "edit-replace", @@ -113,6 +117,10 @@ const FILE_ITEMS = [ const EDIT_ITEMS = [ { id: "edit-undo", disabled: true }, { id: "edit-redo", disabled: true }, + { id: "edit-style-bold", disabled: true }, + { id: "edit-style-italic", disabled: true }, + { id: "edit-style-underline", disabled: true }, + { id: "edit-style-strikeout", disabled: true }, { id: "edit-find", disabled: true }, { id: "edit-find-next", disabled: true }, { id: "edit-replace", disabled: true }, diff --git a/e2e/specs/current-line-bands.spec.js b/e2e/specs/current-line-bands.spec.js index de3b15a..f2fa962 100644 --- a/e2e/specs/current-line-bands.spec.js +++ b/e2e/specs/current-line-bands.spec.js @@ -1,4 +1,4 @@ -/* global describe, it, before, after, document, window */ +/* global describe, it, before, after, document, window, Event */ /** * The current line's bands: the character count on the first one, and the row structure both bands * have to survive at every interface size. See sublore-meta docs/edit-bar-first-tasks.md, E1 and E5. @@ -48,6 +48,7 @@ const SLOP_PX = 1; */ const CONTROLS = [ ".currentline__comment", + ".currentline__edit-style-bold", ".currentline__style", ".currentline__actor", ".currentline__actor-open", @@ -86,18 +87,30 @@ const BARE_SHORTFALL = { 90: [], 110: [], 150: [".currentline__text"] }; * effect, the drawing order and the three margins, which is what these entries are. Every control * is drawn and every control is reachable through the panel's scroll. * - * Two entries have grown since, and they are here rather than paid for: the Next line button needs - * the scroll at 110 and at 150 per cent in the narrowest window with a waveform above the panel. It - * is the last control in the panel, so the narrow window is where it falls outside. Raising the + * Two entries have grown since, and they are here rather than paid for: the panel's own button row + * needs the scroll at 110 and at 150 per cent in the narrowest window with a waveform above it. It + * is the last row in the panel, so the narrow window is where it falls outside. Raising the * block's opening height again would clear it and would take that height from the grid at every * size, for one control in one configuration out of six. See edit-bar-tasks.md question 1, which is * what actually closes this. */ const SHORTFALL = { 90: { floor: [".currentline__text"], wide: [] }, - 110: { floor: [".currentline__text", ".currentline__subtitle-next-line"], wide: [] }, + 110: { + floor: [ + ".currentline__text", + ".currentline__edit-style-bold", + ".currentline__subtitle-next-line", + ], + wide: [], + }, 150: { - floor: [".currentline__end", ".currentline__text", ".currentline__subtitle-next-line"], + floor: [ + ".currentline__end", + ".currentline__text", + ".currentline__edit-style-bold", + ".currentline__subtitle-next-line", + ], wide: [".currentline__text"], }, }; @@ -1189,6 +1202,41 @@ describe("the current line's bands", () => { }); }); + it("wraps the selected words in a style tag, and takes it off again", async () => { + const lineText = () => + browser.execute(() => document.querySelector(".currentline__text")?.value ?? null); + const select = (word) => + browser.execute((wanted) => { + const box = document.querySelector(".currentline__text"); + const at = box.value.indexOf(wanted); + box.focus(); + box.setSelectionRange(at, at + wanted.length); + box.dispatchEvent(new Event("select", { bubbles: true })); + return at; + }, word); + + const copy = workingCopy("ass/clean/speakers.ass"); + await openSubtitle(toplevel, copy); + await goToRow(toplevel, 1); + const before = await lineText(); + expect(before).toContain("harbour"); + + await select("harbour"); + await clickElement(toplevel, ".currentline__edit-style-bold"); + await waitFor(async () => ((await lineText())?.includes("{\\b1}harbour{\\b0}") ? 1 : null), { + timeout: 15000, + message: "the selected word to be wrapped in a bold tag", + }); + // Only that word moved: the rest of the line is what it was. + expect(await lineText()).toBe(before.replace("harbour", "{\\b1}harbour{\\b0}")); + + await clickElement(toplevel, ".toolbar__edit-undo"); + await waitFor(async () => ((await lineText()) === before ? 1 : null), { + timeout: 15000, + message: "one undo to take the tag back off", + }); + }); + it("turns a line into a comment and back, in one undo step each way", async () => { const flag = () => browser.execute(() => { diff --git a/e2e/wdio.conf.js b/e2e/wdio.conf.js index 694f4c8..0306288 100644 --- a/e2e/wdio.conf.js +++ b/e2e/wdio.conf.js @@ -13,7 +13,7 @@ import { passedTests, recordPassedTest, resetTally } from "./lib/tally.js"; * Every spec that exists must run. WebdriverIO does not reliably fail a run that executed nothing, * so the count is asserted here. Bump it when you add a test; see e2e/README.md. */ -const EXPECTED_TESTS = 275; +const EXPECTED_TESTS = 276; // Keeps a run out of the real data dir. Created once in the launcher; workers inherit the value. process.env.SUBLORE_E2E_DATA_HOME ??= mkdtempSync(path.join(os.tmpdir(), "sublore-e2e-")); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3ed64f7..3311890 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -210,6 +210,7 @@ pub fn run() -> tauri::Result<()> { subtitle::subtitle_set_texts, subtitle::subtitle_set_field, subtitle::subtitle_set_comment, + subtitle::subtitle_toggle_style, subtitle::subtitle_set_times, subtitle::subtitle_insert, subtitle::subtitle_delete, diff --git a/src-tauri/src/subtitle/mod.rs b/src-tauri/src/subtitle/mod.rs index 54e8970..4877a1c 100644 --- a/src-tauri/src/subtitle/mod.rs +++ b/src-tauri/src/subtitle/mod.rs @@ -16,6 +16,7 @@ use sublore_edit::diff::{CuePatch, CueView}; use sublore_edit::history::Run; use sublore_edit::plan::{self, Edit}; use sublore_edit::session::EditSession; +use sublore_formats::override_tags::StyleFlag; use sublore_formats::{parse, AssField, Newline, SubtitleDocument, SubtitleFormat}; use sublore_io::atomic::save_with_backup; use sublore_io::backup::BackupStore; @@ -333,6 +334,55 @@ pub async fn subtitle_set_field( .await } +/// One of the four inline style flags, over a stretch of one cue's text. Spelled the way the +/// interface names them, so a value the enum does not hold is refused by the deserializer rather +/// than reaching the planner. See edit-bar-tasks.md B11. +#[derive(Clone, Copy, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StyleFlagDto { + Bold, + Italic, + Underline, + Strikeout, +} + +impl From for StyleFlag { + fn from(flag: StyleFlagDto) -> Self { + match flag { + StyleFlagDto::Bold => StyleFlag::Bold, + StyleFlagDto::Italic => StyleFlag::Italic, + StyleFlagDto::Underline => StyleFlag::Underline, + StyleFlagDto::Strikeout => StyleFlag::Strikeout, + } + } +} + +/// Turn one flag on or off over a stretch of a cue's text. `from` and `to` are byte offsets into +/// the text as the file spells it, and equal offsets are a caret rather than a selection. +#[tauri::command] +pub async fn subtitle_toggle_style( + app: AppHandle, + state: State<'_, SubtitleState>, + revision: u64, + cue: usize, + flag: StyleFlagDto, + from: usize, + to: usize, +) -> Result { + edited( + &app, + state.slot(), + revision, + Edit::ToggleStyle { + cue, + flag: flag.into(), + from, + to, + }, + ) + .await +} + /// Whether one cue is a line a player draws. Refused on a format that has no descriptor to /// rewrite, and the panel draws that control greyed instead of asking. See edit-bar-tasks.md B8. #[tauri::command] diff --git a/src/App.tsx b/src/App.tsx index a97399f..c152602 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,7 +48,7 @@ import { type Menu, } from "./types/chrome"; import { type EpisodeFileView } from "./types/project"; -import { type CueRow } from "./types/subtitle"; +import { type CueRow, type StyleFlagName } from "./types/subtitle"; import "./App.css"; /** @@ -181,6 +181,17 @@ const NEW_CUE_MS = 2000; const LEAD_IN_MS = 100; const LEAD_OUT_MS = 350; +/** + * The four inline style flags, in the order row three of the panel draws them. Each writes its own + * override tag into the line's text and is its own undo step. See edit-bar-tasks.md B11. + */ +const STYLE_FLAGS: { id: CommandId; flag: StyleFlagName; label: string }[] = [ + { id: "edit.style-bold", flag: "bold", label: en.menu.edit.bold }, + { id: "edit.style-italic", flag: "italic", label: en.menu.edit.italic }, + { id: "edit.style-underline", flag: "underline", label: en.menu.edit.underline }, + { id: "edit.style-strikeout", flag: "strikeout", label: en.menu.edit.strikeout }, +]; + export default function App() { // Every HTML layer registers here while it is open, and the video surface hides for as long as // the set is not empty (decision 1, T8). @@ -424,7 +435,7 @@ export default function App() { * blur a menu click causes, which is why the split can still read it; a cursor on another row * leaves it unmatched and the split greyed. */ - const [caret, setCaret] = useState<{ index: number; offset: number } | null>(null); + const [caret, setCaret] = useState<{ index: number; offset: number; to: number } | null>(null); // The chooser is modal and answers on its own thread, so a second one asked for while it is up // would sit behind the first. Every chooser the chrome raises is raised here, so one flag covers // them all. @@ -1069,6 +1080,17 @@ export default function App() { enabled: subtitle.summary !== null && subtitle.cues.length > 0 && ready, run: () => selectAtPlayhead(), }, + ...STYLE_FLAGS.map(({ id, flag, label }): Command => ({ + id, + label, + // A caret in the line's own editor is what it writes at, so it wants one on this row. + enabled: activeCue !== null && caret !== null && caret.index === selection.active, + run: () => { + if (caret !== null && selection.active !== null) { + void subtitle.toggleStyle(selection.active, flag, caret.offset, caret.to); + } + }, + })), { id: "subtitle.insert", label: en.menu.subtitles.insert, @@ -1235,6 +1257,10 @@ export default function App() { items: [ "edit.undo", "edit.redo", + "edit.style-bold", + "edit.style-italic", + "edit.style-underline", + "edit.style-strikeout", "edit.find", "edit.find-next", "edit.replace", @@ -1499,8 +1525,10 @@ export default function App() { multiline={subtitle.summary?.format !== "ass"} flushRef={flushLine} onDraftChange={setLineEdited} - onCaret={(offset) => - setCaret(selection.active === null ? null : { index: selection.active, offset }) + onCaret={(offset, to) => + setCaret( + selection.active === null ? null : { index: selection.active, offset, to }, + ) } onCommit={subtitle.setText} onCommitTimes={subtitle.setTimes} diff --git a/src/components/CurrentLine.tsx b/src/components/CurrentLine.tsx index eecca56..e9d54e9 100644 --- a/src/components/CurrentLine.tsx +++ b/src/components/CurrentLine.tsx @@ -32,10 +32,11 @@ type CurrentLineProps = { /** Told whenever the box holds text the document does not: that is unsaved work too. */ onDraftChange: (pending: boolean) => void; /** - * Where the caret is in the text box, as a UTF-8 byte offset, which is what a split counts in. - * Reported rather than read back later because the click that splits blurs the box first. + * Where the selection is in the text box, as UTF-8 byte offsets, which is what a split and a + * style toggle both count in. Reported rather than read back later because the click that acts + * on it blurs the box first. */ - onCaret: (offset: number) => void; + onCaret: (from: number, to: number) => void; onCommit: (cue: number, text: string) => Promise; onCommitTimes: (cue: number, startMs: number, endMs: number) => Promise; /** Every row of the open document, for the speakers it already names. See D6. */ @@ -345,7 +346,8 @@ export default function CurrentLine({ /** A range reports where it starts, which is where the text would divide. */ function reportCaret(box: HTMLTextAreaElement) { - onCaret(byteOffset(box.value, box.selectionStart)); + // Both ends, because a style toggle wraps a selection and a split needs only the near one. + onCaret(byteOffset(box.value, box.selectionStart), byteOffset(box.value, box.selectionEnd)); } function onType(value: string) { @@ -805,6 +807,10 @@ export default function CurrentLine({ {/* Band 3, the commands the panel carries. Row three of the reference puts the style buttons first and Next line last, so it goes at the end and the others arrive before it. */}
+ {commandButton("edit.style-bold")} + {commandButton("edit.style-italic")} + {commandButton("edit.style-underline")} + {commandButton("edit.style-strikeout")} {commandButton("subtitle.next-line")}