diff --git a/.github/workflows/build-desktop-release.yml b/.github/workflows/build-desktop-release.yml
index 6fa9f8417e..b856e1ef34 100644
--- a/.github/workflows/build-desktop-release.yml
+++ b/.github/workflows/build-desktop-release.yml
@@ -171,6 +171,46 @@ jobs:
name: static
path: static
+ e2e-test:
+ name: E2E Test Shard ${{ matrix.shard }}
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ shard: [1, 2, 3]
+ needs: [ compile-cljs ]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+
+ - name: Download The Static Asset
+ uses: actions/download-artifact@v3
+ with:
+ name: static
+ path: static
+
+ - name: Set up Node
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ cache: 'yarn'
+ cache-dependency-path: |
+ yarn.lock
+ static/yarn.lock
+
+ - name: Fetch yarn deps for E2E test
+ run: |
+ yarn install
+ (cd static && yarn install && yarn rebuild:all)
+ env:
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: true
+
+ - name: Run Playwright test
+ run: xvfb-run -- npx playwright test --reporter github --shard=${{ matrix.shard }}/3
+ env:
+ LOGSEQ_CI: true
+ DEBUG: "pw:api"
+ RELEASE: true # skip dev only test
+
build-linux:
runs-on: ubuntu-20.04
needs: [ compile-cljs ]
@@ -436,7 +476,7 @@ jobs:
nightly-release:
if: ${{ github.event_name == 'schedule' || github.event.inputs.build-target == 'nightly' }}
- needs: [ build-macos-x64, build-macos-arm64, build-linux, build-windows, build-android ]
+ needs: [ build-macos-x64, build-macos-arm64, build-linux, build-windows, build-android, e2e-test ]
runs-on: ubuntu-20.04
steps:
- name: Download MacOS x64 Artifacts
@@ -503,7 +543,7 @@ jobs:
release:
# NOTE: For now, we only have beta channel to be released on Github
if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.build-target == 'beta' }}
- needs: [ build-macos-x64, build-macos-arm64, build-linux, build-windows ]
+ needs: [ build-macos-x64, build-macos-arm64, build-linux, build-windows, e2e-test ]
runs-on: ubuntu-20.04
steps:
- name: Download MacOS x64 Artifacts
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 1bc29bab0f..cec721ad49 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -6,8 +6,8 @@ android {
applicationId "com.logseq.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 59
- versionName "0.9.6"
+ versionCode 61
+ versionName "0.9.8"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
diff --git a/android/app/src/main/java/com/logseq/app/FsWatcher.java b/android/app/src/main/java/com/logseq/app/FsWatcher.java
index 3b07e9b5d9..9d441c9e78 100644
--- a/android/app/src/main/java/com/logseq/app/FsWatcher.java
+++ b/android/app/src/main/java/com/logseq/app/FsWatcher.java
@@ -92,10 +92,23 @@ public class FsWatcher extends Plugin {
shouldRead = true;
}
- URI dir = (new File(mPath)).toURI();
- URI fpath = f.toURI();
+ Uri dir = Uri.fromFile(new File(mPath));
+ Uri fpath = Uri.fromFile(f);
+ String relpath = null;
- obj.put("path", Normalizer.normalize(dir.relativize(fpath).toString(), Normalizer.Form.NFC));
+ if (fpath.getPath().startsWith(dir.getPath())) {
+ relpath = fpath.getPath().substring(dir.getPath().length());
+ if (relpath.startsWith("/")) {
+ relpath = relpath.substring(1);
+ }
+ relpath = Uri.decode(relpath);
+ } else {
+ Log.e("FsWatcher", "file path not under watch path");
+ return;
+ }
+
+
+ obj.put("path", Normalizer.normalize(relpath, Normalizer.Form.NFC));
obj.put("dir", Uri.fromFile(new File(mPath))); // Uri is for Android. URI is for RFC compatible
JSObject stat;
diff --git a/e2e-tests/editor.spec.ts b/e2e-tests/editor.spec.ts
index d680ae5912..a79d0cdd88 100644
--- a/e2e-tests/editor.spec.ts
+++ b/e2e-tests/editor.spec.ts
@@ -1,6 +1,15 @@
import { expect } from '@playwright/test'
import { test } from './fixtures'
-import { createRandomPage, enterNextBlock, modKey } from './utils'
+import {
+ createRandomPage,
+ enterNextBlock,
+ modKey,
+ repeatKeyPress,
+ moveCursor,
+ selectCharacters,
+ getSelection,
+ getCursorPos,
+} from './utils'
import { dispatch_kb_events } from './util/keyboard-events'
import * as kb_events from './util/keyboard-events'
@@ -619,3 +628,192 @@ test('should keep correct undo and redo seq after indenting or outdenting the bl
await page.keyboard.press(modKey + '+Shift+z')
await expect(page.locator('textarea >> nth=0')).toHaveText("aaa bbb")
})
+
+test.describe('Text Formatting', () => {
+ const formats = [
+ { name: 'bold', prefix: '**', postfix: '**', shortcut: modKey + '+b' },
+ { name: 'italic', prefix: '*', postfix: '*', shortcut: modKey + '+i' },
+ {
+ name: 'strikethrough',
+ prefix: '~~',
+ postfix: '~~',
+ shortcut: modKey + '+Shift+s',
+ },
+ // {
+ // name: 'underline',
+ // prefix: '',
+ // postfix: '',
+ // shortcut: modKey + '+u',
+ // },
+ ]
+
+ for (const format of formats) {
+ test.describe(`${format.name} formatting`, () => {
+ test('Applying to an empty selection inserts placeholder formatting and places cursor correctly', async ({
+ page,
+ block,
+ }) => {
+ await createRandomPage(page)
+
+ const text = 'Lorem ipsum'
+ await block.mustFill(text)
+
+ // move the cursor to the end of Lorem
+ await repeatKeyPress(page, 'ArrowLeft', text.length - 'ipsum'.length)
+ await page.keyboard.press('Space')
+
+ // Apply formatting
+ await page.keyboard.press(format.shortcut)
+
+ await expect(page.locator('textarea >> nth=0')).toHaveText(
+ `Lorem ${format.prefix}${format.postfix} ipsum`
+ )
+
+ // Verify cursor position
+ const cursorPos = await getCursorPos(page)
+ expect(cursorPos).toBe(' ipsum'.length + format.prefix.length)
+ })
+
+ test('Applying to an entire block encloses the block in formatting and places cursor correctly', async ({
+ page,
+ block,
+ }) => {
+ await createRandomPage(page)
+
+ const text = 'Lorem ipsum-dolor sit.'
+ await block.mustFill(text)
+
+ // Select the entire block
+ await page.keyboard.press(modKey + '+a')
+
+ // Apply formatting
+ await page.keyboard.press(format.shortcut)
+
+ await expect(page.locator('textarea >> nth=0')).toHaveText(
+ `${format.prefix}${text}${format.postfix}`
+ )
+
+ // Verify cursor position
+ const cursorPosition = await getCursorPos(page)
+ expect(cursorPosition).toBe(format.prefix.length + text.length)
+ })
+
+ test('Applying and then removing from a word connected with a special character correctly formats and then reverts', async ({
+ page,
+ block,
+ }) => {
+ await createRandomPage(page)
+
+ await block.mustFill('Lorem ipsum-dolor sit.')
+
+ // Select 'ipsum'
+ // Move the cursor to the desired position
+ await moveCursor(page, -16)
+
+ // Select the desired length of text
+ await selectCharacters(page, 5)
+
+ // Apply formatting
+ await page.keyboard.press(format.shortcut)
+
+ // Verify that 'ipsum' is formatted
+ await expect(page.locator('textarea >> nth=0')).toHaveText(
+ `Lorem ${format.prefix}ipsum${format.postfix}-dolor sit.`
+ )
+
+ // Re-select 'ipsum'
+ // Move the cursor to the desired position
+ await moveCursor(page, -5)
+
+ // Select the desired length of text
+ await selectCharacters(page, 5)
+
+ // Remove formatting
+ await page.keyboard.press(format.shortcut)
+ await expect(page.locator('textarea >> nth=0')).toHaveText(
+ 'Lorem ipsum-dolor sit.'
+ )
+
+ // Verify the word 'ipsum' is still selected
+ const selection = await getSelection(page)
+ expect(selection).toBe('ipsum')
+ })
+ })
+ }
+})
+
+test.describe('Always auto-pair symbols', () => {
+ // Define the symbols that should be auto-paired
+ const autoPairSymbols = [
+ { name: 'square brackets', prefix: '[', postfix: ']' },
+ { name: 'curly brackets', prefix: '{', postfix: '}' },
+ { name: 'parentheses', prefix: '(', postfix: ')' },
+ // { name: 'angle brackets', prefix: '<', postfix: '>' },
+ { name: 'backtick', prefix: '`', postfix: '`' },
+ // { name: 'single quote', prefix: "'", postfix: "'" },
+ // { name: 'double quote', prefix: '"', postfix: '"' },
+ ]
+
+ for (const symbol of autoPairSymbols) {
+ test(`${symbol.name} auto-pairing`, async ({ page }) => {
+ await createRandomPage(page)
+
+ // Type prefix and check that the postfix is automatically added
+ page.type('textarea >> nth=0', symbol.prefix, { delay: 100 })
+ await expect(page.locator('textarea >> nth=0')).toHaveText(
+ `${symbol.prefix}${symbol.postfix}`
+ )
+
+ // Check that the cursor is positioned correctly between the prefix and postfix
+ const CursorPos = await getCursorPos(page)
+ expect(CursorPos).toBe(symbol.prefix.length)
+ })
+ }
+})
+
+test.describe('Auto-pair symbols only with text selection', () => {
+ const autoPairSymbols = [
+ // { name: 'tilde', prefix: '~', postfix: '~' },
+ { name: 'asterisk', prefix: '*', postfix: '*' },
+ { name: 'underscore', prefix: '_', postfix: '_' },
+ { name: 'caret', prefix: '^', postfix: '^' },
+ { name: 'equal', prefix: '=', postfix: '=' },
+ { name: 'slash', prefix: '/', postfix: '/' },
+ { name: 'plus', prefix: '+', postfix: '+' },
+ ]
+
+ for (const symbol of autoPairSymbols) {
+ test(`Only auto-pair ${symbol.name} with text selection`, async ({
+ page,
+ block,
+ }) => {
+ await createRandomPage(page)
+
+ // type the symbol
+ page.type('textarea >> nth=0', symbol.prefix, { delay: 100 })
+
+ // Verify that there is no auto-pairing
+ await expect(page.locator('textarea >> nth=0')).toHaveText(symbol.prefix)
+
+ // remove prefix
+ await page.keyboard.press('Backspace')
+
+ // add text
+ await block.mustType('Lorem')
+ // select text
+ await page.keyboard.press(modKey + '+a')
+
+ // Type the prefix
+ await page.type('textarea >> nth=0', symbol.prefix, { delay: 100 })
+
+ // Verify that an additional postfix was automatically added around 'Lorem'
+ await expect(page.locator('textarea >> nth=0')).toHaveText(
+ `${symbol.prefix}Lorem${symbol.postfix}`
+ )
+
+ // Verify 'Lorem' is selected
+ const selection = await getSelection(page)
+ expect(selection).toBe('Lorem')
+ })
+ }
+})
\ No newline at end of file
diff --git a/e2e-tests/utils.ts b/e2e-tests/utils.ts
index e61023a8aa..22472a74b3 100644
--- a/e2e-tests/utils.ts
+++ b/e2e-tests/utils.ts
@@ -237,3 +237,68 @@ export async function navigateToStartOfBlock(page: Page, block: Block) {
await page.keyboard.press('ArrowLeft')
}
}
+
+/**
+ * Repeats a key press a certain number of times.
+ * @param {Page} page - The Page object.
+ * @param {string} key - The key to press.
+ * @param {number} times - The number of times to press the key.
+ * @return {Promise} - Promise which resolves when the key press repetition is done.
+ */
+export async function repeatKeyPress(page: Page, key: string, times: number): Promise {
+ for (let i = 0; i < times; i++) {
+ await page.keyboard.press(key);
+ }
+}
+
+/**
+ * Moves the cursor a certain number of characters to the right (positive value) or left (negative value).
+ * @param {Page} page - The Page object.
+ * @param {number} shift - The number of characters to move the cursor. Positive moves to the right, negative to the left.
+ * @return {Promise} - Promise which resolves when the cursor has moved.
+ */
+export async function moveCursor(page: Page, shift: number): Promise {
+ const direction = shift < 0 ? 'ArrowLeft' : 'ArrowRight';
+ const absShift = Math.abs(shift);
+ await repeatKeyPress(page, direction, absShift);
+}
+
+/**
+ * Selects a certain length of text in a textarea to the right of the cursor.
+ * @param {Page} page - The Page object.
+ * @param {number} length - The number of characters to select.
+ * @return {Promise} - Promise which resolves when the text selection is done.
+ */
+export async function selectCharacters(page: Page, length: number): Promise {
+ await page.keyboard.down('Shift');
+ await repeatKeyPress(page, 'ArrowRight', length);
+ await page.keyboard.up('Shift');
+}
+
+/**
+ * Retrieves the selected text in a textarea.
+ * @param {Page} page - The page object.
+ * @return {Promise} - Promise which resolves to the selected text or null.
+ */
+export async function getSelection(page: Page): Promise {
+ const selection = await page.evaluate(() => {
+ const textarea = document.querySelector('textarea')
+ return textarea?.value.substring(textarea.selectionStart, textarea.selectionEnd) || null
+ })
+
+ return selection
+}
+
+/**
+ * Retrieves the current cursor position in a textarea.
+ * @param {Page} page - The page object.
+ * @return {Promise} - Promise which resolves to the cursor position or null.
+ */
+export async function getCursorPos(page: Page): Promise {
+ const cursorPosition = await page.evaluate(() => {
+ const textarea = document.querySelector('textarea');
+ return textarea ? textarea.selectionStart : null;
+ });
+
+ return cursorPosition;
+}
diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj
index 2893ab95c3..9aab440474 100644
--- a/ios/App/App.xcodeproj/project.pbxproj
+++ b/ios/App/App.xcodeproj/project.pbxproj
@@ -27,6 +27,7 @@
D3D62A0C275C928F0003FBDC /* FileContainer.m in Sources */ = {isa = PBXBuildFile; fileRef = D3D62A0B275C928F0003FBDC /* FileContainer.m */; };
FE647FF427BDFEDE00F3206B /* FsWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF327BDFEDE00F3206B /* FsWatcher.swift */; };
FE647FF627BDFEF500F3206B /* FsWatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = FE647FF527BDFEF500F3206B /* FsWatcher.m */; };
+ FE96D6102A1B811A001ECE32 /* SharedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE96D60F2A1B811A001ECE32 /* SharedData.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -83,6 +84,7 @@
DE5650F4AD4E2242AB9C012D /* Pods-Logseq.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Logseq.debug.xcconfig"; path = "Target Support Files/Pods-Logseq/Pods-Logseq.debug.xcconfig"; sourceTree = ""; };
FE647FF327BDFEDE00F3206B /* FsWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FsWatcher.swift; sourceTree = ""; };
FE647FF527BDFEF500F3206B /* FsWatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FsWatcher.m; sourceTree = ""; };
+ FE96D60F2A1B811A001ECE32 /* SharedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedData.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -156,6 +158,7 @@
children = (
5FFF7D7927E4E70700B00DA8 /* ShareViewController.entitlements */,
5FFF7D6C27E343FA00B00DA8 /* ShareViewController.swift */,
+ FE96D60F2A1B811A001ECE32 /* SharedData.swift */,
5FFF7D6E27E343FA00B00DA8 /* MainInterface.storyboard */,
5FFF7D7127E343FA00B00DA8 /* Info.plist */,
);
@@ -345,6 +348,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ FE96D6102A1B811A001ECE32 /* SharedData.swift in Sources */,
5FFF7D6D27E343FA00B00DA8 /* ShareViewController.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -438,7 +442,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -492,7 +496,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -515,7 +519,7 @@
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
- MARKETING_VERSION = 0.9.6;
+ MARKETING_VERSION = 0.9.8;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -542,7 +546,7 @@
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
- MARKETING_VERSION = 0.9.6;
+ MARKETING_VERSION = 0.9.8;
PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -565,9 +569,9 @@
INFOPLIST_FILE = ShareViewController/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareViewController;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
- MARKETING_VERSION = 0.9.6;
+ MARKETING_VERSION = 0.9.8;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
@@ -592,9 +596,9 @@
INFOPLIST_FILE = ShareViewController/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = ShareViewController;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
- MARKETING_VERSION = 0.9.6;
+ MARKETING_VERSION = 0.9.8;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.logseq.logseq.ShareViewController;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/ios/App/ShareViewController/Info.plist b/ios/App/ShareViewController/Info.plist
index 6e05b6afa8..ae080feea2 100644
--- a/ios/App/ShareViewController/Info.plist
+++ b/ios/App/ShareViewController/Info.plist
@@ -8,6 +8,8 @@
NSExtensionActivationRule
+ NSExtensionActivationDictionaryVersion
+ 2
NSExtensionActivationSupportsFileWithMaxCount
5
NSExtensionActivationSupportsImageWithMaxCount
@@ -17,9 +19,9 @@
NSExtensionActivationSupportsText
NSExtensionActivationSupportsWebPageWithMaxCount
- 1
+ 3
NSExtensionActivationSupportsWebURLWithMaxCount
- 1
+ 3
NSExtensionActivationUsesStrictMatching
diff --git a/ios/App/ShareViewController/ShareViewController.swift b/ios/App/ShareViewController/ShareViewController.swift
index 44d824f8a9..79d1b0845c 100644
--- a/ios/App/ShareViewController/ShareViewController.swift
+++ b/ios/App/ShareViewController/ShareViewController.swift
@@ -9,182 +9,180 @@
import MobileCoreServices
import Social
import UIKit
-
-class ShareItem {
- public var title: String?
- public var type: String?
- public var url: String?
-}
+import UniformTypeIdentifiers
class ShareViewController: UIViewController {
-
- private var shareItems: [ShareItem] = []
-
+
+ private var sharedData: SharedData = SharedData.init(resources: [])
+
var groupContainerUrl: URL? {
return FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.logseq.logseq")
}
-
+
override public func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
+ super.viewDidAppear(animated)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
+ self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
+ }
}
-
+
private func sendData() {
- let queryItems = shareItems.map {
+ let encoder: JSONEncoder = JSONEncoder()
+ let data = try? encoder.encode(self.sharedData)
+ let queryPayload = String(decoding: data!, as: UTF8.self)
+
+ let queryItems =
[
URLQueryItem(
- name: "title",
- value: $0.title?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
- URLQueryItem(name: "description", value: ""),
- URLQueryItem(
- name: "type",
- value: $0.type?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
- URLQueryItem(
- name: "url",
- value: $0.url?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
+ name: "payload",
+ value: queryPayload.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
]
- }.flatMap({ $0 })
var urlComps = URLComponents(string: "logseq://shared?")!
urlComps.queryItems = queryItems
openURL(urlComps.url!)
}
-
- fileprivate func createSharedFileUrl(_ url: URL?) -> String {
-
- let copyFileUrl = groupContainerUrl!.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + "/" + url!
+
+ fileprivate func createSharedFileUrl(_ url: URL?) -> URL? {
+ let tempFilename = url!
.lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
- try? Data(contentsOf: url!).write(to: URL(string: copyFileUrl)!)
-
+ let copyFileUrl = groupContainerUrl!.appendingPathComponent(tempFilename)
+ try? Data(contentsOf: url!).write(to: copyFileUrl)
return copyFileUrl
}
-
- func saveScreenshot(_ image: UIImage) -> String {
-
+
+ // Screenshots, shared images from some system App are passed as UIImage
+ func saveUIImage(_ image: UIImage) -> URL? {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd-HH-mm-ss"
-
- let copyFileUrl = groupContainerUrl!.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
- + dateFormatter.string(from: Date()) + ".png"
-
+ let filename = dateFormatter.string(from: Date()) + ".png"
+
+ let copyFileUrl = groupContainerUrl!.appendingPathComponent(filename)
+
do {
- try image.pngData()?.write(to: URL(string: copyFileUrl)!)
+ try image.pngData()?.write(to: copyFileUrl)
return copyFileUrl
} catch {
print(error.localizedDescription)
- return ""
+ return nil
}
}
-
+
+ // Can be a path or a web URL
fileprivate func handleTypeUrl(_ attachment: NSItemProvider)
- async throws -> ShareItem
+ async throws -> SharedResource
{
let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil)
let url = results as! URL?
- let shareItem: ShareItem = ShareItem()
-
+
+ var res = SharedResource()
+
if url!.isFileURL {
- shareItem.title = url!.lastPathComponent
- shareItem.type = "application/" + url!.pathExtension.lowercased()
- shareItem.url = createSharedFileUrl(url)
+ res.name = url!.lastPathComponent
+ res.ext = url!.pathExtension
+ res.type = url!.pathExtensionAsMimeType()
+ res.url = createSharedFileUrl(url)
} else {
- shareItem.title = url!.absoluteString
- shareItem.url = url!.absoluteString
- shareItem.type = "text/plain"
+ res.name = url!.absoluteString
+ res.type = "text/plain"
}
-
- return shareItem
+
+ return res
}
-
+
fileprivate func handleTypeText(_ attachment: NSItemProvider)
- async throws -> ShareItem
+ async throws -> SharedResource?
{
- let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil)
- let shareItem: ShareItem = ShareItem()
- let text = results as! String
- shareItem.title = text
- shareItem.type = "text/plain"
-
- return shareItem
+ let item = try await attachment.loadItem(forTypeIdentifier: kUTTypeText as String, options: nil)
+ self.sharedData.text = item as? String
+ return nil
}
-
+
fileprivate func handleTypeMovie(_ attachment: NSItemProvider)
- async throws -> ShareItem
+ async throws -> SharedResource
{
let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil)
- let shareItem: ShareItem = ShareItem()
-
+
let url = results as! URL?
- shareItem.title = url!.lastPathComponent
- shareItem.type = "video/" + url!.pathExtension.lowercased()
- shareItem.url = createSharedFileUrl(url)
-
- return shareItem
+
+ let name = url!.lastPathComponent
+ let ext = url!.pathExtension.lowercased()
+ let type = url!.pathExtensionAsMimeType()
+ let sharedUrl = createSharedFileUrl(url)
+
+ let res = SharedResource(name: name, ext: ext, type: type, url: sharedUrl)
+
+ return res
}
-
+
fileprivate func handleTypeImage(_ attachment: NSItemProvider)
- async throws -> ShareItem
+ async throws -> SharedResource
{
let data = try await attachment.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil)
-
- let shareItem: ShareItem = ShareItem()
+
+ var res = SharedResource()
+
switch data {
case let image as UIImage:
- shareItem.title = "screenshot"
- shareItem.type = "image/png"
- shareItem.url = self.saveScreenshot(image)
+ res.url = self.saveUIImage(image)
+ res.ext = "png"
+ res.name = res.url?.lastPathComponent
+ res.type = res.url?.pathExtensionAsMimeType()
case let url as URL:
- shareItem.title = url.lastPathComponent
- shareItem.type = "image/" + url.pathExtension.lowercased()
- shareItem.url = self.createSharedFileUrl(url)
+ res.name = url.lastPathComponent
+ res.ext = url.pathExtension.lowercased()
+ res.type = url.pathExtensionAsMimeType()
+ res.url = self.createSharedFileUrl(url)
default:
print("Unexpected image data:", type(of: data))
}
-
- return shareItem
+
+ return res
}
-
-
+
+
override public func viewDidLoad() {
super.viewDidLoad()
-
- shareItems.removeAll()
-
- let extensionItem = extensionContext?.inputItems.first as! NSExtensionItem
+
+ sharedData.empty()
+ let inputItems = extensionContext?.inputItems as! [NSExtensionItem]
Task {
try await withThrowingTaskGroup(
- of: ShareItem.self,
+ of: SharedResource?.self,
body: { taskGroup in
-
- for attachment in extensionItem.attachments! {
- if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
- taskGroup.addTask {
- return try await self.handleTypeUrl(attachment)
- }
- } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
- taskGroup.addTask {
- return try await self.handleTypeText(attachment)
- }
- } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
- taskGroup.addTask {
- return try await self.handleTypeMovie(attachment)
- }
- } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
- taskGroup.addTask {
- return try await self.handleTypeImage(attachment)
+ for extensionItem in inputItems {
+ for attachment in extensionItem.attachments! {
+ if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
+ taskGroup.addTask {
+ return try await self.handleTypeUrl(attachment)
+ }
+ } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
+ taskGroup.addTask {
+ return try await self.handleTypeText(attachment)
+ }
+ } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
+ taskGroup.addTask {
+ return try await self.handleTypeMovie(attachment)
+ }
+ } else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
+ taskGroup.addTask {
+ return try await self.handleTypeImage(attachment)
+ }
}
}
}
-
+
for try await item in taskGroup {
- self.shareItems.append(item)
+ if let item = item {
+ self.sharedData.resources.append(item)
+ }
}
})
-
+
self.sendData()
-
+
}
}
-
+
@discardableResult
@objc func openURL(_ url: URL) -> Bool {
var responder: UIResponder? = self
@@ -196,6 +194,14 @@ class ShareViewController: UIViewController {
}
return false
}
-
+
+
+}
+
+extension URL {
+ func pathExtensionAsMimeType() -> String? {
+ let type = UTType(filenameExtension: self.pathExtension)
+ return type?.preferredMIMEType
+ }
}
diff --git a/ios/App/ShareViewController/SharedData.swift b/ios/App/ShareViewController/SharedData.swift
new file mode 100644
index 0000000000..9bfe184022
--- /dev/null
+++ b/ios/App/ShareViewController/SharedData.swift
@@ -0,0 +1,25 @@
+//
+// SharedData.swift
+// ShareViewController
+//
+// Created by Mono Wang on 5/22/23.
+//
+
+import Foundation
+
+public struct SharedResource: Decodable, Encodable {
+ var name: String?
+ var ext: String?
+ var type: String?
+ var url: URL?
+}
+
+public struct SharedData: Decodable, Encodable {
+ var text: String?
+ var resources: [SharedResource]
+
+ mutating func empty() {
+ text = nil
+ resources = []
+ }
+}
diff --git a/resources/package.json b/resources/package.json
index 75ad337bf9..28ff89818d 100644
--- a/resources/package.json
+++ b/resources/package.json
@@ -1,7 +1,7 @@
{
"name": "Logseq",
"productName": "Logseq",
- "version": "0.9.6",
+ "version": "0.9.8",
"main": "electron.js",
"author": "Logseq",
"license": "AGPL-3.0",
diff --git a/src/main/frontend/dicts/ru.cljc b/src/main/frontend/dicts/ru.cljc
index b61c51833d..1dca5b3d3e 100644
--- a/src/main/frontend/dicts/ru.cljc
+++ b/src/main/frontend/dicts/ru.cljc
@@ -65,6 +65,7 @@
:right-side-bar/switch-theme "Тема"
:right-side-bar/contents "Содержание"
:right-side-bar/page-graph "Граф страницы"
+ :right-side-bar/history "(Dev) Отменить/Повторить историю"
:right-side-bar/block-ref "Ссылка на блок"
:right-side-bar/graph-view "Визуальный граф"
:right-side-bar/all-pages "Все страницы"
@@ -151,10 +152,31 @@
:color/pink "Розовый"
:editor/copy "Копировать"
:editor/cut "Вырезать"
+ :editor/expand-block-children "Раскрыть всё"
+ :editor/collapse-block-children "Свернуть всё"
+ :editor/delete-selection "Удалить выбранные блоки"
+ :editor/cycle-todo "Изменить статус TODO текущего элемента"
+ :dev/show-page-data "(Dev) Показать данные страницы"
+ :dev/show-block-data "(Dev) Показать данные блока"
+ :dev/show-block-ast "(Dev) Показать AST блока"
+ :dev/show-page-ast "(Dev) Показать AST страницы"
+ :content/copy-export-as "Копировать / Экспортировать как.."
+ :content/copy-block-url "Копировать URL блока"
:content/copy-block-ref "Копировать ссылку блока"
:content/copy-block-emebed "Копировать встроенный блок"
+ :content/copy-ref "Скопировать эту ссылку"
+ :content/delete-ref "Удалить эту ссылку"
+ :content/replace-with-text "Заменить на текст"
+ :content/replace-with-embed "Заменить на встроенный элемент"
:content/open-in-sidebar "Открыть в боковой панели"
:content/click-to-edit "Нажмите для редактирования"
+ :context-menu/make-a-flashcard "Создать карточку"
+ :context-menu/toggle-number-list "Переключить номерной список"
+ :context-menu/preview-flashcard "Предварительный просмотр карточки"
+ :context-menu/make-a-template "Создать шаблон"
+ :context-menu/input-template-name "Как назовём шаблон?"
+ :context-menu/template-include-parent-block "Включить родительский блок в шаблон?"
+ :context-menu/template-exists-warning "Шаблон уже существует!"
:settings-page/git-confirm "Необходимо перезапустить приложение после изменения настроек Git."
:settings-page/git-switcher-label "Включить автокоммит в Git"
:settings-page/git-commit-delay "Задержка автокоммита Git в секундах"
@@ -294,6 +316,10 @@
:plugin/update "Обновить"
:plugin/check-update "Проверить обновления"
:plugin/check-all-updates "Проверить все обновления"
+ :plugin/found-updates "Новые обновления"
+ :plugin/found-n-updates "Найдено обновлений {1}"
+ :plugin/update-all-selected "Обновить все выбранные"
+ :plugin/updates-downloading "Загрузка обновлений"
:plugin/refresh-lists "Обновить списки"
:plugin/enabled "Включено"
:plugin/disabled "Отключено"
@@ -345,7 +371,8 @@
:file-sync/other-user-graph "Текущий локальный граф привязан к удаленному графу другого пользователя. Поэтому синхронизацию начать нельзя."
:file-sync/graph-deleted "Текущий удаленный граф был удален"
-
+ :file-sync/rsapi-cannot-upload-err "Невозможно начать синхронизацию, пожалуйста, проверьте правильное ли установлено локальное время."
+
:notification/clear-all "Очистить всё"
:shortcut.category/basics "Базовые"
@@ -355,16 +382,17 @@
:shortcut.category/block-command-editing "Команды редактирования блока"
:shortcut.category/block-selection "Выделение блоков (нажмите Esc для отмены)"
:shortcut.category/toggle "Переключатели"
+ :shortcut.category/whiteboard "Интерактивная доска"
:shortcut.category/others "Разное"
:command.date-picker/complete "Выбор даты: Выбрать указанный день"
:command.date-picker/prev-day "Выбор даты: Выбрать предыдущий день"
:command.date-picker/next-day "Выбор даты: Выбрать следующий день"
:command.date-picker/prev-week "Выбор даты: Выбрать предыдущую неделю"
:command.date-picker/next-week "Выбор даты: Выбрать следующую неделю"
- :command.pdf/previous-page "Предыдущая страница текущего PDF"
- :command.pdf/next-page "Следующая страница текущего PDF"
- :command.pdf/close "Закрыть текущий просмотр PDF"
- :command.pdf/find "Pdf: Поиск текста в текущем PDF-документе"
+ :command.pdf/previous-page "PDF: Предыдущая страница текущего PDF"
+ :command.pdf/next-page "PDF: Следующая страница текущего PDF"
+ :command.pdf/close "PDF: Закрыть текущий просмотр PDF"
+ :command.pdf/find "PDF: Поиск текста в текущем PDF-документе"
:command.auto-complete/complete "Автодополнение: Использовать выбранный элемент"
:command.auto-complete/prev "Автодополнение: Выбрать предыдущий"
:command.auto-complete/next "Автодополнение: Выбрать следующий"
@@ -400,7 +428,7 @@
:command.editor/replace-block-reference-at-point "Заменить ссылку на блок своим содержимым в указанном месте"
:command.editor/paste-text-in-one-block-at-point "Вставить текст в один блок в указанном месте"
:command.editor/insert-youtube-timestamp "Вставить временную метку на Youtube"
- :command.editor/cycle-todo "Переключить статус данной задачи (TODO)"
+ :command.editor/cycle-todo "Изменить статус TODO текущего элемента"
:command.editor/up "Переместить курсор вверх / Выбрать вверх"
:command.editor/down "Переместить курсор вниз / Выбрать вниз"
:command.editor/left "Переместить курсор влево / Открыть выбранный блок в начале"
@@ -427,6 +455,32 @@
:command.editor/select-parent "Выбрать родительский блок"
:command.editor/zoom-in "Увеличить / Вперед"
:command.editor/zoom-out "Уменьшить / Назад"
+ :command.editor/toggle-undo-redo-mode "Переключить режим отменить/повторить (глобально или только на странице)"
+ :command.editor/toggle-number-list "Переключить режим нумерованный список"
+ :command.whiteboard/select "Выбрать инструмент"
+ :command.whiteboard/pan "Прокруктка"
+ :command.whiteboard/portal "Добавить блок или страницу"
+ :command.whiteboard/pencil "Карандаш"
+ :command.whiteboard/highlighter "Маркер"
+ :command.whiteboard/eraser "Ластик"
+ :command.whiteboard/connector "Соединитель"
+ :command.whiteboard/text "Текст"
+ :command.whiteboard/rectangle "Прямоугольник"
+ :command.whiteboard/ellipse "Эллипс"
+ :command.whiteboard/reset-zoom "Сбросить масштаб"
+ :command.whiteboard/zoom-to-fit "Показать все элементы"
+ :command.whiteboard/zoom-to-selection "Показать элемент"
+ :command.whiteboard/zoom-out "Уменьшить"
+ :command.whiteboard/zoom-in "Увеличить"
+ :command.whiteboard/send-backward "Переместить назад"
+ :command.whiteboard/send-to-back "На задний план"
+ :command.whiteboard/bring-forward "Переместить вперёд"
+ :command.whiteboard/bring-to-front "На передний план"
+ :command.whiteboard/lock "Блокировать"
+ :command.whiteboard/unlock "Разблокировать"
+ :command.whiteboard/group "Группировать"
+ :command.whiteboard/ungroup "Разгруппировать"
+ :command.whiteboard/toggle-grid "Переключить отображение сетки"
:command.ui/toggle-brackets "Переключить отображение скобок"
:command.go/search-in-page "Поиск блоков на текущей странице"
:command.go/electron-find-in-page "Поиск текста на странице"
@@ -466,7 +520,7 @@
:command.ui/toggle-help "Переключить помощь"
:command.ui/toggle-theme "Переключение между темной/светлой темой"
:command.ui/toggle-contents "Переключить Контент на боковой панели"
- ;; :ui/open-new-window "Открыть другое окно"
+ ;; :command.ui/open-new-window "Открыть новое окно"
:command.command/toggle-favorite "Добавить или удалить из избранного"
:command.editor/open-file-in-default-app "Открыть файл в программе по умолчанию"
:command.editor/open-file-in-directory "Открыть файл в родительском каталоге"
diff --git a/src/main/frontend/fs/sync.cljs b/src/main/frontend/fs/sync.cljs
index c332f4a576..c668222d91 100644
--- a/src/main/frontend/fs/sync.cljs
+++ b/src/main/frontend/fs/sync.cljs
@@ -1777,7 +1777,7 @@
(when (sync-state--valid-to-accept-filewatcher-event? sync-state)
(when (or (:mtime stat) (= type "unlink"))
(go
- (let [path (path-normalize (remove-dir-prefix dir path))
+ (let [path (path-normalize path)
files-meta (and (not= "unlink" type)
(> (util/get-blocks-noncollapse)
(f))]
(when block
- (scroll-to-block block)
+ (util/scroll-to-block block)
(state/exit-editing-and-set-selected-blocks! [block]))))
(defn- select-up-down [direction]
@@ -2509,7 +2508,7 @@
:down util/get-next-block-non-collapsed)
sibling-block (f selected)]
(when (and sibling-block (dom/attr sibling-block "blockid"))
- (scroll-to-block sibling-block)
+ (util/scroll-to-block sibling-block)
(state/exit-editing-and-set-selected-blocks! [sibling-block]))))
(defn- move-cross-boundary-up-down
diff --git a/src/main/frontend/handler/paste.cljs b/src/main/frontend/handler/paste.cljs
index da8e612821..583db899ff 100644
--- a/src/main/frontend/handler/paste.cljs
+++ b/src/main/frontend/handler/paste.cljs
@@ -233,12 +233,20 @@
(state/set-state! :editor/on-paste? true)
(let [clipboard-data (gobj/get e "clipboardData")
html (.getData clipboard-data "text/html")
- text (.getData clipboard-data "text")]
+ text (.getData clipboard-data "text")
+ has-files? (seq (.-files clipboard-data))]
(cond
(and (string/blank? text) (string/blank? html))
+ ;; When both text and html are blank, paste file if exists.
+ ;; NOTE: util/stop is not called here if no file is provided,
+ ;; so the default paste behavior of the native platform will be used.
+ (when has-files?
+ (paste-file-if-exists id e))
+
+ ;; both file attachment and text/html exist
+ (and has-files? (state/preferred-pasting-file?))
(paste-file-if-exists id e)
- (and (seq (.-files clipboard-data)) (state/preferred-pasting-file?))
- (paste-file-if-exists id e)
+
:else
(let [text' (or (when (gp-util/url? text)
(wrap-macro-url text))
diff --git a/src/main/frontend/mobile/deeplink.cljs b/src/main/frontend/mobile/deeplink.cljs
index f1f398cdbc..57647355bc 100644
--- a/src/main/frontend/mobile/deeplink.cljs
+++ b/src/main/frontend/mobile/deeplink.cljs
@@ -9,7 +9,8 @@
[frontend.handler.route :as route-handler]
[frontend.mobile.intent :as intent]
[frontend.state :as state]
- [frontend.util.text :as text-util]))
+ [frontend.util.text :as text-util]
+ [logseq.graph-parser.util :as gp-util]))
(def *link-to-another-graph (atom false))
@@ -70,8 +71,14 @@
(= hostname "shared")
(let [result (into {} (map (fn [key]
[(keyword key) (.get search-params key)])
- ["title" "url" "type"]))]
- (intent/handle-result result))
+ ["title" "url" "type" "payload"]))]
+ (if (:payload result)
+ (let [raw (gp-util/safe-decode-uri-component (:payload result))
+ payload (-> raw
+ js/JSON.parse
+ (js->clj :keywordize-keys true))]
+ (intent/handle-payload payload))
+ (intent/handle-result result)))
:else
nil)))
diff --git a/src/main/frontend/mobile/intent.cljs b/src/main/frontend/mobile/intent.cljs
index 36639972c8..d44da915b5 100644
--- a/src/main/frontend/mobile/intent.cljs
+++ b/src/main/frontend/mobile/intent.cljs
@@ -152,7 +152,92 @@
(gp-util/safe-decode-uri-component v)
v))])))
-(defn handle-result [result]
+(defn- handle-asset-file [url format]
+ (p/let [basename (node-path/basename url)
+ label (-> basename util/node-path.name)
+ path (editor-handler/get-asset-path basename)
+ _file (p/catch
+ (.copy Filesystem (clj->js {:from url :to path}))
+ (fn [error]
+ (log/error :copy-file-error {:error error})))
+ url (util/format "../assets/%s" basename)
+ url-link (editor-handler/get-asset-file-link format url label true)]
+ url-link))
+
+(defn- handle-payload-resource
+ [{:keys [type name ext url] :as resource} format]
+ (if url
+ (cond
+ (contains? (set/union config/doc-formats config/media-formats)
+ (keyword ext))
+ (handle-asset-file url format)
+
+ :else
+ (notification/show!
+ [:div
+ "Parsing current shared content are not supported. Please report the following codes on "
+ [:a {:href "https://github.com/logseq/logseq/issues/new?labels=from:in-app&template=bug_report.yaml"
+ :target "_blank"} "Github"]
+ ". We will look into it soon."
+ [:pre.code (with-out-str (pprint/pprint resource))]] :warning false))
+
+ (cond
+ (= type "text/plain")
+ name
+
+ :else
+ (notification/show!
+ [:div
+ "Parsing current shared content are not supported. Please report the following codes on "
+ [:a {:href "https://github.com/logseq/logseq/issues/new?labels=from:in-app&template=bug_report.yaml"
+ :target "_blank"} "Github"]
+ ". We will look into it soon."
+ [:pre.code (with-out-str (pprint/pprint resource))]] :warning false))))
+
+(defn handle-payload
+ "Mobile share intent handler v2, use complex payload to support more types of content."
+ [payload]
+ ;; use :text template, use {url} as rich text placeholder
+ (p/let [page (or (state/get-current-page) (string/lower-case (date/journal-name)))
+ format (db/get-page-format page)
+
+ template (get-in (state/get-config)
+ [:quick-capture-templates :text]
+ "**{time}** [[quick capture]]: {text} {url}")
+ {:keys [text resources]} payload
+ text (or text "")
+ rich-content (-> (p/all (map (fn [resource]
+ (handle-payload-resource resource format))
+ resources))
+ (p/then (partial string/join "\n")))]
+ (when (or (not-empty text) (not-empty rich-content))
+ (let [time (date/get-current-time)
+ date-ref-name (date/today)
+ content (-> template
+ (string/replace "{time}" time)
+ (string/replace "{date}" date-ref-name)
+ (string/replace "{text}" text)
+ (string/replace "{url}" rich-content))
+ edit-content (state/get-edit-content)
+ edit-content-blank? (string/blank? edit-content)
+ edit-content-include-capture? (and (not-empty edit-content)
+ (string/includes? edit-content "[[quick capture]]"))]
+ (if (and (state/editing?) (not edit-content-include-capture?))
+ (if edit-content-blank?
+ (editor-handler/insert content)
+ (editor-handler/insert (str "\n" content)))
+
+ (do
+ (editor-handler/escape-editing)
+ (js/setTimeout #(editor-handler/api-insert-new-block! content {:page page
+ :edit-block? true
+ :replace-empty-target? true})
+ 100)))))))
+
+
+(defn handle-result
+ "Mobile share intent handler v1, legacy. Only for Android"
+ [result]
(let [result (decode-received-result result)]
(when-let [type (:type result)]
(cond
diff --git a/src/main/frontend/util.cljc b/src/main/frontend/util.cljc
index 9e3e7a043c..e23f89900e 100644
--- a/src/main/frontend/util.cljc
+++ b/src/main/frontend/util.cljc
@@ -417,6 +417,16 @@
%))
(take-while (complement nil?) (iterate #(.-parentElement %) element)))))
+#?(:cljs
+ (defn element-visible?
+ [element]
+ (when element
+ (when-let [r (.getBoundingClientRect element)]
+ (and (>= (.-top r) 0)
+ (<= (+ (.-bottom r) 64)
+ (or (.-innerHeight js/window)
+ (js/document.documentElement.clientHeight))))))))
+
#?(:cljs
(defn element-top [elem top]
(when elem
@@ -467,6 +477,19 @@
([animate?]
(scroll-to (app-scroll-container-node) 0 animate?))))
+#?(:cljs
+ (defn scroll-to-block
+ "Scroll into the view to vertically align a non-visible block to the centre
+ of the visible area"
+ ([block]
+ (scroll-to-block block true))
+ ([block animate?]
+ (when block
+ (when-not (element-visible? block)
+ (.scrollIntoView block
+ #js {:behavior (if animate? "smooth" "auto")
+ :block "center"}))))))
+
#?(:cljs
(defn link?
[node]
@@ -1416,16 +1439,6 @@
(fn [resolve]
(load url resolve)))))
-#?(:cljs
- (defn element-visible?
- [element]
- (when element
- (when-let [r (.getBoundingClientRect element)]
- (and (>= (.-top r) 0)
- (<= (+ (.-bottom r) 64)
- (or (.-innerHeight js/window)
- (js/document.documentElement.clientHeight))))))))
-
#?(:cljs
(defn copy-image-to-clipboard
[src]
diff --git a/src/main/frontend/version.cljs b/src/main/frontend/version.cljs
index 2b900d6c56..c7942737be 100644
--- a/src/main/frontend/version.cljs
+++ b/src/main/frontend/version.cljs
@@ -1,3 +1,3 @@
(ns ^:no-doc frontend.version)
-(defonce version "0.9.6")
+(defonce version "0.9.8")