eleven / messenger Public

composer: inline basics only — blocks conversion removed, _underscores_ italicize, opt-out escapes (#890) (#934)

Paul's call after the stage-3 device pass: composing sticks to the
basics on every platform. The web composer drops its list/quote/fence
input rules and ⌘⇧7/8/9 (edit-loaded structure still behaves; the FEED
keeps rendering block markdown from bots, history and hand-typing).
iOS blocks died unmerged with #929; the Mac twin was never built.

_underscores_ now italicize as you type (never intraword — snake_case
stays whole) on web and native, drift-gated like the other rules.

And opting out of format-as-you-type now means what it says: typed
markers ESCAPE at send (\*x\* renders as literal *x* everywhere) and
only the WYSIWYG shortcuts format — pinned byte-for-byte on both
serializer twins, including the line-leading '- '/'N.'/'>' guards.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 0f7a7fc9d1c6ed658f2eb8dbed8bc0c6c6609ff7 parent 509cc13
14 files changed, +158 −72
  • changelog/2026-07-14-composer-basics.md +7 −0
  • ios/LChat/Views/ChatView.swift +3 −1
  • ios/LChat/Views/ComposerField.swift +2 −2
  • ios/LChat/Views/NativeChatScreen.swift +3 −1
  • ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift +2 −2
  • ios/LChatCore/Sources/LChatCore/ComposerRules.swift +4 −23
  • ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift +37 −4
  • ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift +22 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift +8 −17
  • ios/LChatMac/MacComposerField.swift +2 −2
  • ios/LChatMac/NativeFeedView.swift +3 −1
  • test/composer.test.mjs +23 −0
  • web/static/composer-md.js +28 −3
  • web/static/composer-pm.js +14 −16
diff --git a/changelog/2026-07-14-composer-basics.md b/changelog/2026-07-14-composer-basics.md
new file mode 100644
index 0000000..a4bbc0d
--- /dev/null
+++ b/changelog/2026-07-14-composer-basics.md
@@ -0,0 +1,7 @@
+🤖 Formatting as you type now sticks to the basics — **bold**, *italics*,
+`code` and ~strikethrough~ — and `_underscores_` make italics too. Typing
+"- " or "> " no longer converts into lists and quotes while you compose
+(those still render beautifully when a bot sends them, and in older
+messages). And turning "Format as you type" off now means exactly what it
+says: your asterisks and underscores stay ordinary characters for everyone
+reading, and only the formatting shortcuts (⌘B, ⌘I and friends) style text.
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 198b639..7ce5b39 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -555,7 +555,9 @@ struct ChatView: View {
private func send() {
// The wire carries serialized markdown (== the display text for
// unstyled drafts); the bridge stays one plain string either way.
- let wire = composerController.serializedMarkdown()
+ // Opted out of format-as-you-type: typed markers escape, so they
+ // render as literal characters — only the WYSIWYG formats.
+ let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
bridge.sendText(wire.isEmpty ? text : wire) { sent in
// Only clear on success: if the socket wasn't ready, keep the text so
// nothing is lost and the user can retry.
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index 0f6092a..5d56d45 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -190,9 +190,9 @@ final class ComposerFieldController {
tv.insertText(text)
}
- func serializedMarkdown() -> String {
+ func serializedMarkdown(escapeLiterals: Bool = false) -> String {
guard let tv = view else { return "" }
- return composerMarkdown(of: tv.attributedText ?? NSAttributedString())
+ return composerMarkdown(of: tv.attributedText ?? NSAttributedString(), escapeLiterals: escapeLiterals)
}
func focus() {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 68e013a..98e19d9 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -1459,7 +1459,9 @@ struct NativeChatScreen: View {
// draft otherwise. Commands and routing above always read `draft` (the
// display text), mirroring the web's plain/markdown split.
private var wireDraft: String {
- let wire = composerController.serializedMarkdown()
+ // Opted out of format-as-you-type = typed markers are literal and
+ // must render literally: escape them; only the WYSIWYG formats.
+ let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
return wire.isEmpty ? draft : wire
}
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift b/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift
index 8d05da7..1b657bd 100644
--- a/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift
+++ b/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift
@@ -61,6 +61,6 @@ public func composerInlineNodes(of a: NSAttributedString) -> [InlineNode] {
// The editor's wire form: what sends when the draft was composed rich.
// For an unstyled draft this equals the display string byte-for-byte (the
// zero-escape guarantee, fuzz-pinned in ComposerSerializeTests).
-public func composerMarkdown(of a: NSAttributedString) -> String {
- serializeComposer([.paragraph(composerInlineNodes(of: a))])
+public func composerMarkdown(of a: NSAttributedString, escapeLiterals: Bool = false) -> String {
+ serializeComposer([.paragraph(composerInlineNodes(of: a))], escapeLiterals: escapeLiterals)
}
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerRules.swift b/ios/LChatCore/Sources/LChatCore/ComposerRules.swift
index 27467c8..7e1432f 100644
--- a/ios/LChatCore/Sources/LChatCore/ComposerRules.swift
+++ b/ios/LChatCore/Sources/LChatCore/ComposerRules.swift
@@ -20,18 +20,14 @@ public struct InlineCompletion: Equatable {
public let mark: RuleMark
}
-public enum BlockTrigger: Equatable {
- case bullet
- case ordered(start: Int)
- case quote
- case fence
-}
-
// Order matters: strong before em, so the ** closer never half-fires * (the
-// composer-pm.js comment, kept).
+// composer-pm.js comment, kept). Underscores italicize too — never intraword
+// (the lookbehind keeps snake_case whole). Block conversion was deliberately
+// removed (composers stick to the inline basics; blocks stay render-only).
private let inlineRules: [(NSRegularExpression, RuleMark)] = [
(try! NSRegularExpression(pattern: "\\*\\*([^*\\s](?:[^*]*[^*\\s])?)\\*\\*$"), .bold),
(try! NSRegularExpression(pattern: "(?<!\\*)\\*([^*\\s](?:[^*]*[^*\\s])?)\\*$"), .italic),
+ (try! NSRegularExpression(pattern: "(?<![\\w_])_([^_\\s](?:[^_]*[^_\\s])?)_$"), .italic),
(try! NSRegularExpression(pattern: "`([^`\\s](?:[^`]*[^`\\s])?)`$"), .code),
(try! NSRegularExpression(pattern: "(?<!~)~([^~\\s](?:[^~]*[^~\\s])?)~$"), .strike),
]
@@ -49,19 +45,4 @@ public func inlineCompletion(lineUpToCaret line: String) -> InlineCompletion? {
return nil
}
-private let bulletRE = try! NSRegularExpression(pattern: "^\\s*([-*])\\s$")
-private let orderedRE = try! NSRegularExpression(pattern: "^(\\d+)\\.\\s$")
-private let quoteRE = try! NSRegularExpression(pattern: "^\\s*>\\s$")
-private let fenceRE = try! NSRegularExpression(pattern: "^```$")
-public func blockTrigger(lineUpToCaret line: String) -> BlockTrigger? {
- let ns = line as NSString
- let full = NSRange(location: 0, length: ns.length)
- if bulletRE.firstMatch(in: line, range: full) != nil { return .bullet }
- if let m = orderedRE.firstMatch(in: line, range: full) {
- return .ordered(start: Int(ns.substring(with: m.range(at: 1))) ?? 1)
- }
- if quoteRE.firstMatch(in: line, range: full) != nil { return .quote }
- if fenceRE.firstMatch(in: line, range: full) != nil { return .fence }
- return nil
-}
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift b/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift
index 149e6ef..1e9bb1f 100644
--- a/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift
+++ b/ios/LChatCore/Sources/LChatCore/ComposerSerialize.swift
@@ -54,7 +54,36 @@ private func isWhitespace(_ s: String) -> Bool {
// MARK: - Inline state machine
-private func renderInlines(_ nodes: [InlineNode]) -> String {
+// Literal-mode escaping for a plain run (format-as-you-type OFF): marker
+// characters everywhere, block-forming syntax at line starts — the member's
+// typed characters must RENDER as those characters in every feed.
+private func escapeLiteralRun(_ text: String, atLineStart: Bool) -> String {
+ var out = ""
+ out.reserveCapacity(text.count)
+ for ch in text {
+ if ch == "\\" || ch == "`" || ch == "*" || ch == "_" || ch == "~" { out.append("\\") }
+ out.append(ch)
+ }
+ guard atLineStart else { return out }
+ // ^(\s*)([->+])(\s) and ^(\s*\d+)\.(\s) — the web's guardLine, hand-rolled.
+ var chars = Array(out)
+ var i = 0
+ while i < chars.count, chars[i] == " " || chars[i] == "\t" { i += 1 }
+ if i < chars.count {
+ if "->+".contains(chars[i]), i + 1 < chars.count, chars[i + 1] == " " || chars[i + 1] == "\t" {
+ chars.insert("\\", at: i)
+ } else {
+ var j = i
+ while j < chars.count, chars[j].isNumber { j += 1 }
+ if j > i, j < chars.count, chars[j] == ".", j + 1 < chars.count, chars[j + 1] == " " || chars[j + 1] == "\t" {
+ chars.insert("\\", at: j)
+ }
+ }
+ }
+ return String(chars)
+}
+
+private func renderInlines(_ nodes: [InlineNode], escapeLiterals: Bool = false, openedAtLineStart: Bool = true) -> String {
// Pass 1 — break-mark filter: a break keeps a mark only if the mark
// continues on the next node and that node is non-whitespace text.
var filtered: [InlineNode] = []
@@ -144,7 +173,11 @@ private func renderInlines(_ nodes: [InlineNode]) -> String {
let desired = marks(of: run.style)
closeDownTo(desired)
open(desired)
- out += escaped(run.text, style: run.style)
+ if escapeLiterals, run.style.isPlain {
+ out += escapeLiteralRun(run.text, atLineStart: out.isEmpty ? openedAtLineStart : out.hasSuffix("\n"))
+ } else {
+ out += escaped(run.text, style: run.style)
+ }
case .brk(let style):
closeDownTo(marks(of: style))
out += "\n"
@@ -156,12 +189,12 @@ private func renderInlines(_ nodes: [InlineNode]) -> String {
// MARK: - Blocks
-public func serializeComposer(_ blocks: [ComposerBlock]) -> String {
+public func serializeComposer(_ blocks: [ComposerBlock], escapeLiterals: Bool = false) -> String {
var parts: [String] = []
for block in blocks {
switch block {
case .paragraph(let nodes):
- parts.append(renderInlines(nodes))
+ parts.append(renderInlines(nodes, escapeLiterals: escapeLiterals))
case .quote(let nodes):
// "> " before every emitted line — including empty ones, which
// keep the trailing space ("> a\n> \n> b"), matching the web.
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift
index ea89d34..79c5c29 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift
@@ -65,3 +65,25 @@ final class ComposerAttributesTests: XCTestCase {
XCTAssertNotEqual(ComposerStyleAttr(bold), ComposerStyleAttr(.plain))
}
}
+
+// Opt-out escaping (format-as-you-type OFF): typed markers are literal text
+// and must RENDER literally; only WYSIWYG-applied marks keep their markers.
+// The cases mirror test/composer.test.mjs's escaped-mode block byte-for-byte.
+extension ComposerAttributesTests {
+ func testEscapedModeEscapesPlainMarkers() {
+ let a = NSMutableAttributedString(string: "lit *x* _y_ ")
+ a.append(NSAttributedString(string: "bold", attributes: [.composerStyle: ComposerStyleAttr(InlineStyle(bold: true))]))
+ XCTAssertEqual(composerMarkdown(of: a), "lit *x* _y_ **bold**")
+ XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "lit \\*x\\* \\_y\\_ **bold**")
+ }
+
+ func testEscapedModeGuardsLineLeadingBlocks() {
+ let a = NSAttributedString(string: "- a\n2. b\n> q")
+ XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\- a\n2\\. b\n\\> q")
+ }
+
+ func testEscapedModeBackslashAndBacktick() {
+ let a = NSAttributedString(string: "\\* and `tick`")
+ XCTAssertEqual(composerMarkdown(of: a, escapeLiterals: true), "\\\\\\* and \\`tick\\`")
+ }
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift
index fbd52dc..720520b 100644
--- a/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerRulesTests.swift
@@ -26,6 +26,13 @@ final class ComposerRulesTests: XCTestCase {
InlineCompletion(replaceRange: 0..<6, content: "code", mark: .code))
XCTAssertEqual(inlineCompletion(lineUpToCaret: "~gone~"),
InlineCompletion(replaceRange: 0..<6, content: "gone", mark: .strike))
+ XCTAssertEqual(inlineCompletion(lineUpToCaret: "say _it_"),
+ InlineCompletion(replaceRange: 4..<8, content: "it", mark: .italic))
+ }
+
+ func testUnderscoreNeverFiresIntraword() {
+ XCTAssertNil(inlineCompletion(lineUpToCaret: "snake_case_"))
+ XCTAssertNil(inlineCompletion(lineUpToCaret: "a_b_"))
}
func testStrongWinsBeforeEm() {
@@ -52,19 +59,6 @@ final class ComposerRulesTests: XCTestCase {
InlineCompletion(replaceRange: 3..<8, content: "x", mark: .bold))
}
- // MARK: block triggers
-
- func testBlockTriggers() {
- XCTAssertEqual(blockTrigger(lineUpToCaret: "- "), .bullet)
- XCTAssertEqual(blockTrigger(lineUpToCaret: "* "), .bullet)
- XCTAssertEqual(blockTrigger(lineUpToCaret: "3. "), .ordered(start: 3))
- XCTAssertEqual(blockTrigger(lineUpToCaret: "> "), .quote)
- XCTAssertEqual(blockTrigger(lineUpToCaret: "```"), .fence)
- XCTAssertNil(blockTrigger(lineUpToCaret: "-x"))
- XCTAssertNil(blockTrigger(lineUpToCaret: "1.5 "))
- XCTAssertNil(blockTrigger(lineUpToCaret: "x - "))
- }
-
// MARK: drift gate against the web rules
func testRegexLiteralsMatchComposerPmJs() throws {
@@ -74,12 +68,9 @@ final class ComposerRulesTests: XCTestCase {
let literals = [
#"/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/"#,
#"/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/"#,
+ #"/(?<![\w_])_([^_\s](?:[^_]*[^_\s])?)_$/"#,
#"/`([^`\s](?:[^`]*[^`\s])?)`$/"#,
#"/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/"#,
- #"/^\s*([-*])\s$/"#,
- #"/^(\d+)\.\s$/"#,
- #"/^\s*>\s$/"#,
- #"/^```$/"#,
]
for literal in literals {
XCTAssertTrue(js.contains(literal), "composer-pm.js no longer contains \(literal) — mirror the rule change into ComposerRules.swift")
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
index e88c1a1..0042db7 100644
--- a/ios/LChatMac/MacComposerField.swift
+++ b/ios/LChatMac/MacComposerField.swift
@@ -297,9 +297,9 @@ final class MacComposerController {
// The wire form of the draft — serialized markdown from the LChatCore
// twin (vector-pinned against the web serializer). The binding stays the
// display string; this is the getMarkdown to its getText.
- func serializedMarkdown() -> String {
+ func serializedMarkdown(escapeLiterals: Bool = false) -> String {
guard let tv = view, let storage = tv.textStorage else { return "" }
- return composerMarkdown(of: storage)
+ return composerMarkdown(of: storage, escapeLiterals: escapeLiterals)
}
}
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index a704007..5eed7fc 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -1448,7 +1448,9 @@ struct NativeFeedView: View {
// draft otherwise. Commands and routing always read `draft` (the display
// text), mirroring the web's plain/markdown split.
private var wireDraft: String {
- let wire = composerController.serializedMarkdown()
+ // Opted out of format-as-you-type = typed markers are literal and
+ // must render literally: escape them; only the WYSIWYG formats.
+ let wire = composerController.serializedMarkdown(escapeLiterals: !formatAsYouType)
return wire.isEmpty ? draft : wire
}
diff --git a/test/composer.test.mjs b/test/composer.test.mjs
index 0af3d68..5db79d1 100644
--- a/test/composer.test.mjs
+++ b/test/composer.test.mjs
@@ -14,6 +14,7 @@ import {
} from "../web/static/composer-md.js";
import { richTokens, blockTokens } from "../web/static/format.js";
import { composerToken } from "../web/static/ui.js";
+import { parseMessage } from "../web/static/markdown.js";
let failures = 0;
async function test(name, fn) {
@@ -209,3 +210,25 @@ if (failures) {
process.exit(1);
}
console.log("\ncomposer: all tests passed");
+
+await test("escaped mode: plain markers escape, WYSIWYG marks keep theirs", () => {
+ // Mirrors ComposerAttributesTests' escaped-mode cases byte-for-byte.
+ const doc = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [
+ composerSchema.text("lit *x* _y_ "),
+ composerSchema.text("bold", [composerSchema.marks.strong.create()]),
+ ])]);
+ assert.equal(serializeDoc(doc), "lit *x* _y_ **bold**");
+ assert.equal(serializeDoc(doc, { escapeLiterals: true }), "lit \\*x\\* \\_y\\_ **bold**");
+ const blocks = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [
+ composerSchema.text("- a"), composerSchema.nodes.hard_break.create(),
+ composerSchema.text("2. b"), composerSchema.nodes.hard_break.create(),
+ composerSchema.text("> q"),
+ ])]);
+ assert.equal(serializeDoc(blocks, { escapeLiterals: true }), "\\- a\n2\\. b\n\\> q");
+ const tick = composerSchema.node("doc", null, [composerSchema.node("paragraph", null, [
+ composerSchema.text("\\* and `tick`"),
+ ])]);
+ assert.equal(serializeDoc(tick, { escapeLiterals: true }), "\\\\\\* and \\`tick\\`");
+ // Escaped bytes render as the literal characters in the feed.
+ assert.deepEqual(parseMessage("\\*x\\* \\_y\\_"), [{ t: "p", children: [{ t: "text", text: "*x* _y_" }] }]);
+});
diff --git a/web/static/composer-md.js b/web/static/composer-md.js
index b95fa7f..63797de 100644
--- a/web/static/composer-md.js
+++ b/web/static/composer-md.js
@@ -187,6 +187,21 @@ function backticksFor(node, side) {
return result;
}
+// Literal-mode escaping for a plain run: marker characters everywhere, and
+// block-forming syntax at line starts (so a typed "- " or "> " reads back as
+// exactly those characters in every feed). `out` is the serializer's
+// accumulated output — empty or newline-terminated means this run opens a line.
+function escapeLiteralRun(text, out) {
+ let escaped = text.replace(/[\\`*_~]/g, (c) => "\\" + c);
+ const atLineStart = out === "" || out.endsWith("\n");
+ const guardLine = (line) =>
+ line
+ .replace(/^(\s*)([->+])(\s)/, "$1\\$2$3")
+ .replace(/^(\s*\d+)\.(\s)/, "$1\\.$2");
+ if (atLineStart) escaped = guardLine(escaped);
+ return escaped;
+}
+
function safeHref(href) {
return /[\s()]/.test(href) ? "<" + href + ">" : href;
}
@@ -228,7 +243,15 @@ const mdSerializer = new MarkdownSerializer(
state.renderContent(node);
},
text(state, node) {
- state.text(escapeInMarks(node.text, node.marks), false);
+ // escapeLiterals (format-as-you-type OFF): plain-run marker characters
+ // are the member's literal text and must RENDER literally — escape
+ // them. Marked runs keep their generated markers (the WYSIWYG's work)
+ // and the normal in-mark escaping.
+ if (state.options.escapeLiterals && node.marks.length === 0) {
+ state.text(escapeLiteralRun(node.text, state.out), false);
+ } else {
+ state.text(escapeInMarks(node.text, node.marks), false);
+ }
},
},
{
@@ -249,7 +272,7 @@ const mdSerializer = new MarkdownSerializer(
},
);
-export function serializeDoc(doc) {
+export function serializeDoc(doc, options = {}) {
// tightLists: chat lists are always tight (the feed renders them tight, and
// loose serialization would inject \n\n old clients show as blank lines).
// The one escape prosemirror-markdown emits outside our handlers is the
@@ -257,7 +280,9 @@ export function serializeDoc(doc) {
// image syntax). Our zero-escape policy never writes "\!", so stripping it
// is unambiguous — and required: it's how an edit-loaded ![alt](url)
// round-trips byte-identical (the feed renders images literally anyway).
- return mdSerializer.serialize(doc, { tightLists: true }).replace(/\\(!\[)/g, "$1");
+ return mdSerializer
+ .serialize(doc, { tightLists: true, escapeLiterals: !!options.escapeLiterals })
+ .replace(/\\(!\[)/g, "$1");
}
// --- doc ⇄ plaintext offsets ----------------------------------------------------
diff --git a/web/static/composer-pm.js b/web/static/composer-pm.js
index 162016a..d32ae6d 100644
--- a/web/static/composer-pm.js
+++ b/web/static/composer-pm.js
@@ -20,9 +20,9 @@
import {
EditorState, EditorView, TextSelection,
history, undo, redo, keymap, baseKeymap,
- inputRules, InputRule, wrappingInputRule, textblockTypeInputRule, undoInputRule,
- chainCommands, toggleMark, newlineInCode, wrapIn, lift,
- wrapInList, splitListItem, liftListItem,
+ inputRules, InputRule, undoInputRule,
+ chainCommands, toggleMark, newlineInCode,
+ splitListItem,
} from "./vendor/prosemirror.js";
import {
composerSchema as schema,
@@ -54,22 +54,19 @@ function markInputRule(regexp, markType) {
}
function buildInputRules() {
+ // Inline marks ONLY — block conversion (lists/quotes/fences as you type)
+ // was deliberately removed after the native attempt showed the input-side
+ // jank isn't worth it: the composers stick to the basics, and block
+ // markdown stays a render-side ability (bots, history, hand-typed).
return inputRules({
rules: [
// strong before em, so the ** closer never half-fires the * rule
markInputRule(/\*\*([^*\s](?:[^*]*[^*\s])?)\*\*$/, schema.marks.strong),
markInputRule(/(?<!\*)\*([^*\s](?:[^*]*[^*\s])?)\*$/, schema.marks.em),
+ // _underscores_ italicize too — never intraword (snake_case stays put)
+ markInputRule(/(?<![\w_])_([^_\s](?:[^_]*[^_\s])?)_$/, schema.marks.em),
markInputRule(/`([^`\s](?:[^`]*[^`\s])?)`$/, schema.marks.code),
markInputRule(/(?<!~)~([^~\s](?:[^~]*[^~\s])?)~$/, schema.marks.strike),
- wrappingInputRule(/^\s*([-*])\s$/, schema.nodes.bullet_list),
- wrappingInputRule(
- /^(\d+)\.\s$/,
- schema.nodes.ordered_list,
- (match) => ({ order: +match[1] }),
- (match, node) => node.childCount + node.attrs.order === +match[1],
- ),
- wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
- textblockTypeInputRule(/^```$/, schema.nodes.code_block),
],
});
}
@@ -93,9 +90,6 @@ export function createRichComposer(opts = {}) {
"Mod-i": toggleMark(schema.marks.em),
"Mod-e": toggleMark(schema.marks.code),
"Mod-Shift-x": toggleMark(schema.marks.strike),
- "Mod-Shift-7": wrapInList(schema.nodes.ordered_list),
- "Mod-Shift-8": wrapInList(schema.nodes.bullet_list),
- "Mod-Shift-9": chainCommands(wrapIn(schema.nodes.blockquote), lift),
"Mod-z": undo,
"Mod-Shift-z": redo,
"Mod-y": redo,
@@ -248,7 +242,11 @@ export function createRichComposer(opts = {}) {
view.focus();
},
getMarkdown() {
- return serializeDoc(view.state.doc);
+ // Opted out of format-as-you-type = typed markers are literal text and
+ // must RENDER literally: escape them at send, so only WYSIWYG-applied
+ // marks format. Opted in keeps the zero-escape wire (what you type is
+ // what sends).
+ return serializeDoc(view.state.doc, { escapeLiterals: !rulesOn });
},
setMarkdown(v, o = {}) {
swapDoc(parseToDoc(v), o);