Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ class AndroidEditorKeyboardTest {
private lateinit var backend: LuiBackend
private var generation = 1

private fun render(title: String): SemanticsNodeInteraction {
private fun render(title: String, inputPolicy: String = "{}"): SemanticsNodeInteraction {
val registry = logseqExtensionRegistry { it }
backend = LuiBackend(onEvent = { events.add(it) }, extensions = registry)
val ops = JSONArray().put(JSONObject().put("op", "create-node").put("id", 1).put("kind", "root"))
.put(JSONObject().put("op", "create-extension").put("id", 2).put("identifier", "outliner-editor")
.put("fingerprint", registry.registration("outliner-editor")!!.fingerprint))
.put(JSONObject().put("op", "insert-child").put("parent", 1).put("child", 2).put("index", 0))
mapOf("block-id" to "keyboard-block", "title" to title, "caret-utf16-offset" to title.length,
"selection-revision" to 0, "input-policy" to "{}").forEach { (name, value) ->
"selection-revision" to 0, "focus-enabled" to true, "input-policy" to inputPolicy).forEach { (name, value) ->
ops.put(JSONObject().put("op", "set-extension-prop").put("id", 2).put("property", name).put("value", value))
}
assertTrue(backend.lastError, backend.applyJson(JSONObject().put("generation", 1).put("ops", ops).toString()))
Expand Down Expand Up @@ -148,6 +148,32 @@ class AndroidEditorKeyboardTest {
ui.waitUntil(5_000) { events.filterIsInstance<LuiEvent.Extension>().any { it.name == "return" } }
ui.runOnIdle { assertEquals(1, events.filterIsInstance<LuiEvent.Extension>().count { it.name == "return" }) }
}
@Test fun codeEnterInsertsLinesAndShiftEnterCreatesOneBlock() {
val editor = render("6*7", """{"code":true,"multiline":true}""")
editor.performKeyInput { pressKey(Key.Enter) }
editor.assertTextEquals("6*7\n")
editor.performTextInput("last*3\n")
editor.assertTextEquals("6*7\nlast*3\n")
ui.runOnIdle { assertFalse(events.filterIsInstance<LuiEvent.Extension>().any { it.name == "return" }) }
editor.performKeyInput { keyDown(Key.ShiftLeft); pressKey(Key.Enter); keyUp(Key.ShiftLeft) }
ui.waitUntil(5_000) { events.filterIsInstance<LuiEvent.Extension>().any { it.name == "return" } }
ui.runOnIdle { assertEquals(1, events.filterIsInstance<LuiEvent.Extension>().count { it.name == "return" }) }
}

@Test fun codeShiftEnterReplacesUnicodeSelectionAndPreservesHandoffTyping() {
val editor = render("Hello 中文 📝 world", """{"code":true,"multiline":true}""")
editor.performTextInputSelection(TextRange(11, 6))
editor.performKeyInput { keyDown(Key.ShiftRight); pressKey(Key.Enter); keyUp(Key.ShiftRight) }
ui.runOnIdle {
val split = events.filterIsInstance<LuiEvent.Extension>().single { it.name == "return" }
assertEquals("Hello world", split.values["title"])
assertEquals(6, split.values["caret-utf16-offset"])
}
editor.performTextInput("Next")
acknowledge("next-block", " world", 0)
ui.onNodeWithTag("field.outliner.block.next-block").assertIsFocused().assertTextEquals("Next world")
}

@Test fun savedWritingAssistanceFlagsReachTheNativeKeyboardAndUpdateWithoutLosingSelection() {
val context = androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext
val preferences = context.getSharedPreferences("logseq", android.content.Context.MODE_PRIVATE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
Expand Down Expand Up @@ -238,15 +239,19 @@ class AndroidFeedbackRecordingTest(private val scenario: String) {
val inputBounds = editor.getUnclippedBoundsInRoot()
val outputBounds = results.getUnclippedBoundsInRoot()
assertTrue("Calculator results occupy the right column", outputBounds.left >= inputBounds.right)
editor.performImeAction()
editor.performKeyInput { pressKey(Key.Enter) }
editor.assertTextEquals("6*7\nlast*3\n")
drain()
val beforeCount = JSONObject(configured).getJSONObject("result").getJSONArray("outlinerRows").length()
assertEquals("Enter stays inside Calculator", beforeCount,
JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows").length())
editor.performKeyInput { keyDown(Key.ShiftLeft); pressKey(Key.Enter); keyUp(Key.ShiftLeft) }
ui.waitUntil(10_000) {
drain()
runCatching { editor.assertDoesNotExist() }.isSuccess
}
val after = JSONObject(request("refresh")).getJSONObject("result").getJSONArray("outlinerRows")
val beforeCount = JSONObject(configured).getJSONObject("result").getJSONArray("outlinerRows").length()
assertEquals("Enter creates exactly one new block", beforeCount + 1, after.length())
results.assertTextEquals("42\n126")
assertEquals("Shift+Enter creates exactly one new block", beforeCount + 1, after.length())
content.performTouchInput { longClick() }
ui.waitUntil(10_000) {
drain()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import kotlinx.coroutines.delay
// Port of the former _OutlinerEditor Dart implementation.
//
// The block title/caret are owned by the OCaml core. Local edits emit
// text-change (debounced 80ms), Enter emits return, backspace at offset 0
// text-change (debounced 80ms), the block-creation key emits return, backspace at offset 0
// emits a structural backspace. Model acknowledgements echo locally
// published titles — those updates are swallowed so typing is never
// reverted mid-keystroke.
Expand Down Expand Up @@ -328,6 +328,10 @@ internal fun OutlinerEditor(context: LuiExtensionContext) {
return@onPreviewKeyEvent true
}
if (event.type == KeyEventType.KeyDown && event.key == Key.Enter && event.isShiftPressed) {
if (code && multiline) {
returnAtSelection()
return@onPreviewKeyEvent true
}
val start = value.selection.min.coerceIn(0, value.text.length)
val end = value.selection.max.coerceIn(start, value.text.length)
val title = value.text.replaceRange(start, end, "\n")
Expand Down
2 changes: 1 addition & 1 deletion apple/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apple/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ let package = Package(
.library(name: "LogseqModel", type: .dynamic, targets: ["LogseqModel"]),
],
dependencies: [
.package(url: "ssh://git@github.com/logseq/lui.git", revision: "d3b8df7a2837bc6aa2f4ca56dc1bfe5ba42dff4e"),
.package(url: "ssh://git@github.com/logseq/lui.git", revision: "f90ee61c2460f7d67d6e169863ff096b50b34d0f"),
.package(url: "https://github.com/appstefan/highlightswift.git", from: "1.1.0")
],
targets: [
Expand Down
6 changes: 4 additions & 2 deletions apple/Sources/Logseq/OutlinerInlineEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,9 @@ private struct NativeOutlinerTextView: UIViewRepresentable {
shouldChangeTextIn range: NSRange,
replacementText replacement: String
) -> Bool {
let isInlineLineBreak = (textView as? FocusRetainingTextView)?.isInsertingLineBreak == true
let isShiftReturn = (textView as? FocusRetainingTextView)?.isInsertingLineBreak == true
let isInlineLineBreak = isShiftReturn && !(NativeEditorInputPolicy.usesCodePresentation(parent.inputPolicy)
&& NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy))
if isAwaitingBlockHandoff {
// Structure events are serialized and rebound to the latest
// editor by the store. Forward them immediately so fast
Expand Down Expand Up @@ -419,7 +421,7 @@ private struct NativeOutlinerTextView: UIViewRepresentable {
parent.onTextChange(deletion.text, deletion.caretUTF16Offset)
return false
}
if replacement == "\n", NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy) { return true }
if replacement == "\n", !isShiftReturn, NativeEditorInputPolicy.allowsLineBreak(parent.inputPolicy) { return true }
if replacement == "\n" {
LGInteractionDiagnostics.editor(textView, blockID: activeBlockID, event: "editor.return")
var submittedText = textView.text ?? ""
Expand Down
57 changes: 57 additions & 0 deletions apple/Tests/UI/localized-dialog-cancel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
import UIKit
@testable import LUIAppleBackend

@main struct LocalizedDialogCancelCheck {
@MainActor static func main() async throws {
for (cancel, delete) in [("Cancel", "Delete"), ("取消", "删除"), ("キャンセル", "削除"), ("Annuler", "Supprimer")] {
let backend = LUIAppleBackend()
let operations: [[String: Any]] = [
["op": "create-node", "id": 1, "kind": "column"],
["op": "create-node", "id": 2, "kind": "dialog"],
["op": "create-node", "id": 3, "kind": "button"],
["op": "create-node", "id": 4, "kind": "button"],
["op": "set-prop", "id": 2, "property": "text", "value": delete],
["op": "set-prop", "id": 2, "property": "style-class", "value": "alert"],
["op": "set-prop", "id": 3, "property": "text", "value": cancel],
["op": "set-prop", "id": 3, "property": "style-class", "value": "quiet cancellation-action"],
["op": "set-prop", "id": 4, "property": "text", "value": delete],
["op": "set-prop", "id": 4, "property": "variant", "value": "destructive"],
["op": "insert-child", "parent": 1, "child": 2, "index": 0],
["op": "insert-child", "parent": 2, "child": 3, "index": 0],
["op": "insert-child", "parent": 2, "child": 4, "index": 1]
]
let json = String(decoding: try JSONSerialization.data(withJSONObject: ["generation": 1, "ops": operations]), as: UTF8.self)
try backend.apply(json: json)
let dialog = backend.model(id: 2)!
guard LUIDialogContentPolicy.cancelActionID(dialog: dialog, backend: backend) == 3,
LUIDialogContentPolicy.nonCancelActionIDs(dialog: dialog, backend: backend) == [4] else {
throw NSError(domain: "LocalizedDialogCancel", code: 1,
userInfo: [NSLocalizedDescriptionKey: "A localized dialog must have exactly one semantic cancellation action: \(cancel)"])
}
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = UIWindow(windowScene: scene)
let host = UIHostingController(rootView: LUISwiftUIRoot(backend: backend, rootID: 1))
window.rootViewController = host
window.makeKeyAndVisible()
var alert: UIAlertController?
for _ in 0..<100 {
if let presented = host.presentedViewController as? UIAlertController {
alert = presented
break
}
try await Task.sleep(for: .milliseconds(20))
}
guard let alert, alert.actions.count == 2,
alert.actions.filter({ $0.style == .cancel }).map(\.title) == [cancel],
alert.actions.filter({ $0.style == .destructive }).map(\.title) == [delete] else {
throw NSError(domain: "LocalizedDialogCancel", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Native alerts must not append another Cancel button: \(cancel)"])
}
host.dismiss(animated: false)
window.isHidden = true
window.rootViewController = nil
}
print("PASS: English, Chinese, Japanese and French alerts each render one Cancel and one Delete")
}
}
15 changes: 11 additions & 4 deletions apple/Tests/UI/outliner-return-handoff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,23 +117,30 @@ struct ReturnHandoffCheck {
print("FAIL: rapid Enter, Shift+Enter, and typing must preserve the new block text; returns=\(model.returns) expected=\(beforeRapid + 1) title=\(model.title.debugDescription) caret=\(editor().selectedRange.location)"); failures += 1
}
for code in [true, false] {
model.inputPolicy = "{\"multiline\":false,\"code\":\(code)}"
model.inputPolicy = "{\"multiline\":\(code),\"code\":\(code)}"
model.title = "x^2"; model.blockID = code ? "code" : "math"; model.caret = 3
await settle()
let beforeReturn = model.returns
type("\n", in: editor())
await settle()
if code {
if model.returns != beforeReturn || model.title != "x^2\n" {
print("FAIL: Enter in Code and Calculator must insert a newline"); failures += 1
}
shiftEnter()
await settle()
}
if model.returns != beforeReturn + 1 || !model.title.isEmpty {
print("FAIL: Enter in Code and Math must create a new block"); failures += 1
print("FAIL: Shift+Enter in Code and Calculator, and Enter in Math, must create a new block"); failures += 1
}
model.title = "🙂alpha beta"; model.blockID += "-newline"; model.caret = 12
await settle()
editor().selectedRange = NSRange(location: 2, length: 5)
let beforeNewline = model.returns
shiftEnter()
if code { type("\n", in: editor()) } else { shiftEnter() }
await settle()
if model.returns != beforeNewline || model.title != "🙂\n beta" || editor().selectedRange.location != 3 {
print("FAIL: Shift+Enter in Code and Math must replace the selected text with a newline"); failures += 1
print("FAIL: the newline key must replace a UTF-16 selection inside Code and Math"); failures += 1
}
}
model.inputPolicy = "{}"
Expand Down
2 changes: 1 addition & 1 deletion logseq.opam
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ pin-depends: [
["melange-transit-core.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"]
["melange-transit-native.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"]
["melange-transit-melange.0.1.2" "git+https://github.com/logseq/melange-transit.git#main"]
["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#d3b8df7a2837bc6aa2f4ca56dc1bfe5ba42dff4e"]
["lui.0.1.0" "git+ssh://git@github.com/logseq/lui.git#f90ee61c2460f7d67d6e169863ff096b50b34d0f"]
["drive.dev" "git+https://github.com/logseq/drive.git#dd735a65ed525d1c36db38ac8e8114aa81d314a1"]
["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#02e18cdc5e0ad672aa223f638eaa3ba52fc9421e"]
["mldoc.dev" "git+https://github.com/logseq/mldoc.git#f11230c367964af2b575524d00779cfb1ffc7147"]
Expand Down
1 change: 1 addition & 0 deletions scripts/test-ios-native-interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
("outliner-backspace-boundary.swift", "OutlinerBackspaceBoundaryCheck"),
("outliner-subtree-selection.swift", "OutlinerSubtreeSelectionCheck"),
("special-block-presentation.swift", "SpecialBlockPresentationCheck"),
("localized-dialog-cancel.swift", "LocalizedDialogCancelCheck"),
("outliner-autosave.swift", "Check")]:
source = directory / filename
source.write_text("@testable import Logseq\n" + (root / "apple/Tests/UI" / filename).read_text())
Expand Down
5 changes: 4 additions & 1 deletion shared/src/logseq/core/node_title.ml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ let builtin db id = get db id "logseq.property/built-in?" = Some (D.Bool true)
let journal db id = get db id "block/journal-day" <> None || tagged db id "logseq.class/Journal"
let can_edit db id = not (builtin db id || journal db id)
let can_rename_page db id = is_page db id && can_edit db id
let can_delete db id = not (builtin db id) && get db id "logseq.property/hide?" <> Some (D.Bool true)
let can_delete ?(now = int_of_float (Unix.gettimeofday () *. 1000.)) db id =
not (builtin db id)
&& get db id "logseq.property/hide?" <> Some (D.Bool true)
&& G.int_value (get db id "block/journal-day") <> Some (Cache_model.journal_day_for_ms now)

let nfc text =
let normalizer = Uunf.create `NFC in
Expand Down
2 changes: 1 addition & 1 deletion shared/src/logseq/core/node_title.mli
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
val is_page : Datascript.db -> int -> bool
val is_class : Datascript.db -> int -> bool
val is_property : Datascript.db -> int -> bool
val can_delete : Datascript.db -> int -> bool
val can_delete : ?now:int -> Datascript.db -> int -> bool
val can_edit : Datascript.db -> int -> bool
val can_rename_page : Datascript.db -> int -> bool
val name : string -> string
Expand Down
4 changes: 2 additions & 2 deletions shared/src/logseq/core/pending_projection.ml
Original file line number Diff line number Diff line change
Expand Up @@ -868,8 +868,8 @@ let rec compile_with_literals literal_blocks db (intent : Ops.pending_intent) =
= Some recycle_eid
then Ok []
else if
not (Node_title.can_delete db page_eid)
then Error "Built-in page cannot be deleted"
not (Node_title.can_delete ~now:value.deleted_at db page_eid)
then Error "Protected page cannot be deleted"
else
let parent =
match one_value db reference "block/parent" with
Expand Down
5 changes: 3 additions & 2 deletions shared/src/logseq/view_graphs.ml
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ let graph_delete_dialog (context : Lui_ui.ui_context) model_source send : t
~label:(View_base.tr (Signal.sample model_source) "Graph deletion actions")
~accessibility_identifier:"toolbar.graph-delete"
[
button ~variant:`ghost
button ~variant:`ghost ~style_class:"cancellation-action"
~accessibility_identifier:
"button.graph-delete.cancel"
~on_press:(press send Model.CancelDeleteGraph)
Expand Down Expand Up @@ -372,7 +372,8 @@ let graph_delete_dialog (context : Lui_ui.ui_context) model_source send : t
"Notice that we can't recover this graph after being \
deleted. Make sure you have backups before deleting it."
[];
button ~accessibility_identifier:"button.graph-delete.cancel"
button ~style_class:"cancellation-action"
~accessibility_identifier:"button.graph-delete.cancel"
~on_press:(press send Model.CancelDeleteGraph)
~text:(View_base.tr (Signal.sample model_source) "Cancel") [];
button ~variant:`destructive
Expand Down
2 changes: 1 addition & 1 deletion shared/src/logseq/view_outliner.ml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ let outliner_editor_view model_source block_id_source title_source caret_source
if row.row_uuid = uuid then row.row_display_type else None) current.outliner_rows in
let special = List.mem display_type [Some "code"; Some "math"] in
StringValue (if special then Yojson.Basic.to_string (`Assoc [
"multiline", `Bool false; "code", `Bool (display_type = Some "code");
"multiline", `Bool (display_type = Some "code"); "code", `Bool (display_type = Some "code");
"emptyPairs", `List [`Assoc ["text", `String "[[]]"; "deleteOffset", `Int 1]]])
else Outliner_state.native_input_policy)) model_source block_id_source);
Lui_ui.extension_property_signal context node "focus-enabled"
Expand Down
Loading
Loading