Skip to content

Commit aa7be73

Browse files
thebanriclaude
andcommitted
fix(editor): code blocks cover only the selected lines and can be left
The code button took in the line whose end a selection started at, and lines sharing a paragraph through line breaks or an image turned the whole paragraph into code. Selections now ignore lines they only touch at the line break, and the lines selected are split into paragraphs of their own first. A note ending in code could not be continued below it: Enter on an empty last code line now ends the block, and Down on the last line or a click below the text opens a plain line after it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d8f367d commit aa7be73

5 files changed

Lines changed: 184 additions & 10 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
### Fixed
10+
- The code block button makes only the selected lines code. A selection starting at the end of a line, or ending at the start of one, no longer takes that line in, and lines that share a paragraph with an image or other lines (split by Shift+Enter or pasted line breaks) become a paragraph of their own instead of turning the whole paragraph into code. The same selection rule applies to the list and checklist buttons.
11+
- A note that ends in a code block can be continued below it: Enter on an empty last code line ends the block, and Down on the last line or a click below the text opens a plain line after it.
12+
913
## [0.1.9] - 2026-09-22
1014

1115
### Changed

‎app/src/text_formatter.cpp‎

Lines changed: 103 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -596,10 +596,21 @@ QList<QTextBlock> blocksIn(QTextDocument *document, int start, int end) {
596596
if (!document || start < 0 || end < start ||
597597
end >= document->characterCount())
598598
return blocks;
599-
for (auto block = document->findBlock(start); block.isValid();
600-
block = block.next()) {
599+
auto first = document->findBlock(start);
600+
auto last = document->findBlock(end);
601+
// A selection starting after the last character of a paragraph, or
602+
// ending before the first one of the next, only touches the line break
603+
// between them: dragging from the end of a line takes in no text of it.
604+
if (end > start) {
605+
if (first != last && first.length() > 1 &&
606+
start == first.position() + first.length() - 1)
607+
first = first.next();
608+
if (first != last && end == last.position())
609+
last = last.previous();
610+
}
611+
for (auto block = first; block.isValid(); block = block.next()) {
601612
blocks.append(block);
602-
if (block.position() + block.length() > end)
613+
if (block == last)
603614
break;
604615
}
605616
return blocks;
@@ -709,15 +720,71 @@ bool TextFormatter::codeActive(QQuickTextDocument *quickDocument, int start,
709720
return any;
710721
}
711722

723+
namespace {
724+
// Code formatting is per paragraph, but one paragraph can hold several lines:
725+
// line breaks (Shift+Enter, or <br> in pasted HTML) and images, each on a line
726+
// of its own. Splits the paragraphs around start and end there, so the lines
727+
// from start to end become paragraphs of their own and formatting them
728+
// leaves the other lines as they are. Returns how far start and end moved.
729+
int isolateLines(QTextCursor &cursor, int start, int end) {
730+
QTextDocument *document = cursor.document();
731+
// After the last selected line first, so start stays where it is.
732+
// A selection ending just after a line break, or starting just before
733+
// one, takes in no text of the line beyond it (as in blocksIn()).
734+
auto block = document->findBlock(end);
735+
QString text = block.text();
736+
int from = end - block.position();
737+
if (end > start && from > 0 && text.at(from - 1) == QChar::LineSeparator)
738+
--from;
739+
for (int i = from; i < text.size(); ++i) {
740+
if (i == 0)
741+
continue;
742+
if (text.at(i) == QChar::LineSeparator) {
743+
cursor.setPosition(block.position() + i);
744+
cursor.deleteChar();
745+
cursor.insertBlock();
746+
break;
747+
}
748+
if (text.at(i) == QChar::ObjectReplacementCharacter) {
749+
cursor.setPosition(block.position() + i);
750+
cursor.insertBlock();
751+
break;
752+
}
753+
}
754+
block = document->findBlock(start);
755+
text = block.text();
756+
from = start - block.position();
757+
if (end > start && from < text.size() && text.at(from) == QChar::LineSeparator)
758+
++from;
759+
for (int i = std::min(from, int(text.size())) - 1; i >= 0; --i) {
760+
if (text.at(i) == QChar::LineSeparator) {
761+
cursor.setPosition(block.position() + i);
762+
cursor.deleteChar();
763+
cursor.insertBlock();
764+
return 0;
765+
}
766+
if (text.at(i) == QChar::ObjectReplacementCharacter) {
767+
if (i + 1 >= text.size())
768+
return 0;
769+
cursor.setPosition(block.position() + i + 1);
770+
cursor.insertBlock();
771+
return 1;
772+
}
773+
}
774+
return 0;
775+
}
776+
} // namespace
777+
712778
void TextFormatter::toggleCode(QQuickTextDocument *quickDocument, int start,
713779
int end) {
714780
auto *document = quickDocument ? quickDocument->textDocument() : nullptr;
715-
const auto blocks = blocksIn(document, start, end);
716-
if (blocks.isEmpty())
781+
if (blocksIn(document, start, end).isEmpty())
717782
return;
718783
const bool code = !codeActive(quickDocument, start, end);
719784
QTextCursor cursor(document);
720785
cursor.beginEditBlock();
786+
const int moved = isolateLines(cursor, start, end);
787+
const auto blocks = blocksIn(document, start + moved, end + moved);
721788
for (const auto &block : blocks) {
722789
if (isImageOnly(block))
723790
continue;
@@ -737,6 +804,37 @@ void TextFormatter::toggleCode(QQuickTextDocument *quickDocument, int start,
737804
cursor.endEditBlock();
738805
}
739806

807+
bool TextFormatter::endEmptyCodeLine(QQuickTextDocument *quickDocument,
808+
int position) {
809+
auto *document = quickDocument ? quickDocument->textDocument() : nullptr;
810+
if (!document || position < 0 || position >= document->characterCount())
811+
return false;
812+
const auto block = document->findBlock(position);
813+
if (!isCodeBlock(block) || !block.text().isEmpty() ||
814+
(block.next().isValid() && isCodeBlock(block.next())))
815+
return false;
816+
QTextCursor cursor(document);
817+
cursor.beginEditBlock();
818+
setCode(block, false);
819+
cursor.endEditBlock();
820+
return true;
821+
}
822+
823+
int TextFormatter::lineAfterCode(QQuickTextDocument *quickDocument) {
824+
auto *document = quickDocument ? quickDocument->textDocument() : nullptr;
825+
if (!document || !isCodeBlock(document->lastBlock()))
826+
return -1;
827+
QTextCursor cursor(document);
828+
cursor.beginEditBlock();
829+
cursor.movePosition(QTextCursor::End);
830+
// The new paragraph starts as a copy of the code line; setCode() takes
831+
// the code formatting off again.
832+
cursor.insertBlock();
833+
setCode(cursor.block(), false);
834+
cursor.endEditBlock();
835+
return cursor.block().position();
836+
}
837+
740838
void TextFormatter::restoreCodeFont(QQuickTextDocument *quickDocument) {
741839
auto *document = quickDocument ? quickDocument->textDocument() : nullptr;
742840
if (!document)

‎app/src/text_formatter.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ class TextFormatter : public QObject {
6060
// Makes the paragraphs from start to end code, or plain text again.
6161
Q_INVOKABLE void toggleCode(QQuickTextDocument *document, int start,
6262
int end);
63+
// Enter on an empty last line of a code block ends the block: the line
64+
// becomes plain text. Returns whether it did.
65+
Q_INVOKABLE bool endEmptyCodeLine(QQuickTextDocument *document, int position);
66+
// A note ending in code gets a plain paragraph after it, so there is
67+
// somewhere to write below the block. Returns that paragraph's position,
68+
// or -1 when the note does not end in code.
69+
Q_INVOKABLE int lineAfterCode(QQuickTextDocument *document);
6370
// Restores the font of empty code lines, which HTML does not keep, so text
6471
// typed there after reopening a note is code too.
6572
Q_INVOKABLE void restoreCodeFont(QQuickTextDocument *document);

‎app/tests/notes_flow.qml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,40 @@ Window {
816816
check(!codeLine(0) && codeLine(1), "The code button did not turn the caret's line back into text")
817817
body.text = ""
818818

819+
// A selection from the end of one line to the start of another takes
820+
// in neither of them, only the lines between.
821+
body.insert(0, "<p>above</p><p>middle</p><p>below</p>")
822+
body.select("above".length, "above\nmiddle\n".length)
823+
window.toggleCode()
824+
check(!codeLine(0) && codeLine(1) && !codeLine(2), "The code button took in the lines around the selection")
825+
body.text = ""
826+
827+
// Lines inside one paragraph, split by line breaks or an image, are
828+
// made code on their own.
829+
body.insert(0, "<p>one<br>two<br>three</p>")
830+
body.select("one\n".length, "one\ntwo".length)
831+
window.toggleCode()
832+
check(!codeLine(0) && codeLine(1) && !codeLine(2), "The code button took in lines sharing the paragraph: " + JSON.stringify(window.plainContent))
833+
check(window.plainContent === "one\ntwo\nthree", "Splitting the paragraph changed the text: " + JSON.stringify(window.plainContent))
834+
body.text = ""
835+
836+
// Enter on an empty last code line ends the block.
837+
body.forceActiveFocus()
838+
typeText("```\ncode\n\nplain")
839+
check(window.plainContent === "code\nplain", "Enter on an empty code line kept it: " + JSON.stringify(window.plainContent))
840+
check(codeLine(0) && !codeLine(1), "Enter on an empty code line did not end the block")
841+
body.select(body.length - 1, body.length)
842+
check(body.cursorSelection.font.family !== "monospace", "Text typed after leaving the block is still monospace")
843+
body.text = ""
844+
845+
// Down on a code block that ends the note opens a plain line under it.
846+
body.forceActiveFocus()
847+
typeText("```\nlast")
848+
input.keyClick(Qt.Key_Down)
849+
typeText("tail")
850+
check(window.plainContent === "last\ntail" && codeLine(0) && !codeLine(1), "Down did not leave the code block: " + JSON.stringify(window.plainContent))
851+
body.text = ""
852+
819853
// The toolbar buttons turn existing lines into a list and back.
820854
// The note is rich text by now, so each line is its own paragraph,
821855
// as pressing Enter makes them.

‎qml/windows/StickyNote.qml‎

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,29 @@ ApplicationWindow {
919919
return true
920920
}
921921

922+
// Enter on an empty last line of a code block ends the block.
923+
function leaveCodeBlock() {
924+
if (!isRichText || hasTextSelection) return false
925+
if (!formatter.endEmptyCodeLine(contentEditor.textDocument, contentEditor.cursorPosition)) return false
926+
typingFont(false)
927+
backend.editContent(contentEditor.text)
928+
autosave.restart()
929+
return true
930+
}
931+
932+
// Down on the last line, or a click below the text, opens a plain line
933+
// after a code block that ends the note; otherwise there is none to reach.
934+
function openLineAfterCode() {
935+
if (!isRichText) return false
936+
const caret = formatter.lineAfterCode(contentEditor.textDocument)
937+
if (caret < 0) return false
938+
contentEditor.cursorPosition = caret
939+
typingFont(false)
940+
backend.editContent(contentEditor.text)
941+
autosave.restart()
942+
return true
943+
}
944+
922945
// The editor keeps the format for the next typed character with its own
923946
// caret, so a change to the paragraph alone would not reach new text.
924947
function typingFont(code) {
@@ -2221,13 +2244,19 @@ ApplicationWindow {
22212244
onSelectionStartChanged: if (noteWindow.selectedImage >= 0 && selectionStart !== noteWindow.selectedImage) noteWindow.selectedImage = -1
22222245
onSelectionEndChanged: if (noteWindow.selectedImage >= 0 && selectionEnd !== noteWindow.selectedImage + 1) noteWindow.selectedImage = -1
22232246

2224-
// Enter on an empty list item ends the list.
2225-
// Enter on a ``` line opens or closes a code block, and on an
2226-
// empty list item ends the list.
2247+
// Enter on a ``` line opens or closes a code block, on an empty
2248+
// last code line ends the block, and on an empty list item
2249+
// ends the list.
22272250
Keys.onReturnPressed: function(event) {
22282251
event.accepted = event.modifiers === Qt.NoModifier
2229-
&& (noteWindow.codeFence() || noteWindow.leaveEmptySubItem() || (noteWindow.isRichText
2230-
&& formatter.endEmptyListItem(contentEditor.textDocument, contentEditor.cursorPosition)))
2252+
&& (noteWindow.codeFence() || noteWindow.leaveCodeBlock() || noteWindow.leaveEmptySubItem()
2253+
|| (noteWindow.isRichText
2254+
&& formatter.endEmptyListItem(contentEditor.textDocument, contentEditor.cursorPosition)))
2255+
}
2256+
Keys.onDownPressed: function(event) {
2257+
const caret = positionToRectangle(cursorPosition)
2258+
event.accepted = event.modifiers === Qt.NoModifier && !noteWindow.hasTextSelection
2259+
&& caret.y >= positionToRectangle(length).y && noteWindow.openLineAfterCode()
22312260
}
22322261
Keys.onEscapePressed: function(event) {
22332262
if (noteWindow.findOpen) {
@@ -2259,7 +2288,9 @@ ApplicationWindow {
22592288
const x = eventPoint.position.x
22602289
const y = eventPoint.position.y
22612290
const link = noteWindow.linkAt(x, y)
2291+
const end = contentEditor.positionToRectangle(contentEditor.length)
22622292
if (link.length > 0) noteWindow.openLink(link)
2293+
else if (y > end.y + end.height && noteWindow.openLineAfterCode()) return
22632294
else noteWindow.selectImageAt(x, y)
22642295
}
22652296
// Double-clicking an image keeps it selected for resizing;

0 commit comments

Comments
 (0)