From 99e9c2820cc0a8571607fd7cd6eb41cd6e9d6bc1 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 15 Sep 2026 12:56:44 +0800 Subject: [PATCH] refactor: share editor schema and drop RN iOS builds Editors now take document TipTap nodes from a shared factory. Workspace and editor helpers move out of the large panes. Store iOS no longer builds from Expo EAS. --- .github/workflows/store-delivery.yml | 69 +-- .gitignore | 3 + .../EditorSource/src/document-nodes.test.mjs | 22 +- apps/ios/EditorSource/src/main.ts | 32 +- apps/{mobile => ios}/Gemfile | 0 apps/{mobile => ios}/Gemfile.lock | 0 apps/ios/fastlane/Fastfile | 12 +- apps/mobile/app.json | 7 +- apps/mobile/eas.json | 15 +- apps/mobile/fastlane/Fastfile | 34 -- apps/mobile/package.json | 1 - .../src/components/LocalTiptapEditor.tsx | 29 +- apps/web/src/components/EditorPane.tsx | 204 +------ .../MobileStandaloneTiptapEditor.tsx | 32 +- apps/web/src/components/PublicSharePage.tsx | 26 +- apps/web/src/components/WorkspaceApp.tsx | 571 +----------------- .../src/components/WorkspaceMobileChrome.tsx | 367 +++++++++++ .../components/editor/editor-pane-helpers.ts | 163 +++++ apps/web/src/lib/workspace-memo-cache.ts | 154 +++++ apps/web/src/lib/workspace-shell.ts | 43 ++ packages/shared/src/content.ts | 27 +- .../shared/src/document-extensions.test.mjs | 52 ++ packages/shared/src/document-extensions.ts | 52 ++ packages/shared/src/index.ts | 1 + scripts/store-delivery.test.mjs | 16 +- tests/worker-bundle-boundary.test.ts | 3 + 26 files changed, 981 insertions(+), 954 deletions(-) rename apps/{mobile => ios}/Gemfile (100%) rename apps/{mobile => ios}/Gemfile.lock (100%) delete mode 100644 apps/mobile/fastlane/Fastfile create mode 100644 apps/web/src/components/WorkspaceMobileChrome.tsx create mode 100644 apps/web/src/components/editor/editor-pane-helpers.ts create mode 100644 apps/web/src/lib/workspace-memo-cache.ts create mode 100644 apps/web/src/lib/workspace-shell.ts create mode 100644 packages/shared/src/document-extensions.test.mjs create mode 100644 packages/shared/src/document-extensions.ts diff --git a/.github/workflows/store-delivery.yml b/.github/workflows/store-delivery.yml index 9d1f45ee..7d3c7908 100644 --- a/.github/workflows/store-delivery.yml +++ b/.github/workflows/store-delivery.yml @@ -32,7 +32,7 @@ on: default: false type: boolean ios_build_number: - description: "Existing App Store Connect build number to submit without rebuilding" + description: "App Store Connect build number from Xcode Cloud. Required for iOS delivery." required: false default: "" type: string @@ -283,72 +283,23 @@ jobs: with: ref: ${{ needs.plan.outputs.release_target }} - - name: Set up Bun - uses: oven-sh/setup-bun@v2 - - - name: Set up EAS CLI - uses: expo/expo-github-action@v8 - with: - eas-version: 21.4.0 - packager: npm - token: ${{ secrets.EXPO_TOKEN }} + - name: Require an Xcode Cloud build number + run: | + if [[ -z "${{ inputs.ios_build_number }}" ]]; then + echo "::error::iOS store delivery uses apps/ios via Xcode Cloud. Pass ios_build_number; do not build from apps/mobile or EAS." + exit 1 + fi - name: Set up Ruby and fastlane uses: ruby/setup-ruby@v1 with: ruby-version: "3.3" bundler-cache: true - working-directory: apps/mobile - - - name: Install dependencies - run: >- - bun install --frozen-lockfile - --cache-dir "$RUNNER_TEMP/edgeever-bun-cache-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - - - name: Build the iOS archive on EAS - if: ${{ inputs.ios_build_number == '' }} - id: build - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - RELEASE_TAG: ${{ inputs.release_tag }} - run: | - test -n "$EXPO_TOKEN" - build_json="$( - eas build \ - --platform ios \ - --profile production \ - --message "Store delivery ${RELEASE_TAG}" \ - --freeze-credentials \ - --non-interactive \ - --wait \ - --json - )" - build_id="$(jq -r 'if type == "array" then .[0].id else .id end' <<<"$build_json")" - build_number="$(jq -r 'if type == "array" then .[0].appBuildVersion else .appBuildVersion end' <<<"$build_json")" - test -n "$build_id" - test "$build_id" != "null" - test -n "$build_number" - test "$build_number" != "null" - echo "build_id=$build_id" >> "$GITHUB_OUTPUT" - echo "build_number=$build_number" >> "$GITHUB_OUTPUT" - working-directory: apps/mobile - - - name: Upload archive to App Store Connect and TestFlight - if: ${{ inputs.ios_build_number == '' }} - env: - EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: | - eas submit \ - --platform ios \ - --profile store-ios \ - --id "${{ steps.build.outputs.build_id }}" \ - --non-interactive \ - --wait - working-directory: apps/mobile + working-directory: apps/ios - name: Submit the uploaded build to App Review env: - APP_STORE_BUILD_NUMBER: ${{ inputs.ios_build_number || steps.build.outputs.build_number }} + APP_STORE_BUILD_NUMBER: ${{ inputs.ios_build_number }} APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.EDGEEVER_APPLE_API_ISSUER }} APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.EDGEEVER_APPLE_API_KEY_ID }} APP_STORE_CONNECT_API_KEY_P8_BASE64: ${{ secrets.EDGEEVER_APPLE_API_KEY_BASE64 }} @@ -373,4 +324,4 @@ jobs: echo "App Store Connect has not exposed build ${APP_STORE_BUILD_NUMBER} yet; retrying in 60 seconds (${attempt}/20)." sleep 60 done - working-directory: apps/mobile + working-directory: apps/ios diff --git a/.gitignore b/.gitignore index e9dae3f2..f1ef6a69 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ credentials.json # Local App Review crash dumps / image self-test screenshots. .tmp-app-review-crashes/ +# Generated Expo iOS tree; store iOS is apps/ios. +apps/mobile/ios/ + # Local brand concept and research drafts. /assets/brand/edgeever-concept-13.png /docs/notion-ai-feature-landscape.en-US.md diff --git a/apps/ios/EditorSource/src/document-nodes.test.mjs b/apps/ios/EditorSource/src/document-nodes.test.mjs index 10499c2f..8324cd54 100644 --- a/apps/ios/EditorSource/src/document-nodes.test.mjs +++ b/apps/ios/EditorSource/src/document-nodes.test.mjs @@ -1,9 +1,9 @@ import { describe, expect, test } from "bun:test"; import { getSchema } from "@tiptap/core"; import Image from "@tiptap/extension-image"; -import { Markdown, MarkdownManager } from "@tiptap/markdown"; import StarterKit from "@tiptap/starter-kit"; import { + createEdgeEverDocumentExtensions, docToMarkdown, IMAGE_GALLERY_NODE_TYPE, markdownToDoc, @@ -12,6 +12,7 @@ import { MergeDivider as SharedMergeDivider, } from "@edgeever/shared"; import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; +import { MarkdownManager } from "@tiptap/markdown"; import { createIosImageGallery, ImageGallery, MergeDivider } from "./document-nodes.ts"; const galleryDoc = { @@ -27,16 +28,15 @@ const galleryDoc = { }; const iosMarkdownManager = new MarkdownManager({ - extensions: [ - StarterKit.configure({ codeBlock: false, link: false }), - Image, - MergeDivider, - ...createEdgeEverMathematics(), - createIosImageGallery(() => "en-US"), - Markdown.configure({ - markedOptions: { gfm: true }, - }), - ], + extensions: createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + starterKit: { codeBlock: false, link: false }, + gallery: createIosImageGallery(() => "en-US"), + pdf: false, + file: false, + pluginEmbed: false, + markdown: true, + }), }); describe("iOS document nodes share the web schema", () => { diff --git a/apps/ios/EditorSource/src/main.ts b/apps/ios/EditorSource/src/main.ts index 85e1e43e..f90322dc 100644 --- a/apps/ios/EditorSource/src/main.ts +++ b/apps/ios/EditorSource/src/main.ts @@ -2,19 +2,16 @@ import "./styles.css"; import "katex/dist/katex.min.css"; import { Graph } from "@antv/x6"; import { Editor } from "@tiptap/core"; -import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; -import { TaskItem, TaskList } from "@tiptap/extension-list"; import Placeholder from "@tiptap/extension-placeholder"; import CodeBlock from "@tiptap/extension-code-block"; -import { TableKit } from "@tiptap/extension-table"; -import { Markdown } from "@tiptap/markdown"; import { EdgeEverLink } from "@edgeever/shared/editor-link"; import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import { NodeSelection } from "@tiptap/pm/state"; import mermaid from "mermaid"; import { toCanvas } from "html-to-image"; import { + createEdgeEverDocumentExtensions, createNativeUnsupportedContentExtensions, diagramDocumentToX6Cells, attachDiagramReader, @@ -45,7 +42,7 @@ import { generateCardCss, } from "@edgeever/shared/note-image-card"; import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; -import { createIosImageGallery, MergeDivider } from "./document-nodes"; +import { createIosImageGallery } from "./document-nodes"; import { createImageInsertTransaction, groupUploadedImages, NATIVE_IMAGE_GALLERY_CSS } from "@edgeever/shared/native-image-gallery"; const galleryStyle = document.createElement("style"); @@ -745,31 +742,26 @@ function createEdgeEverImageExtension() { function buildExtensions(placeholder: string) { return [ - StarterKit.configure({ - codeBlock: false, - link: false, + ...createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + starterKit: { codeBlock: false, link: false }, + image: createEdgeEverImageExtension(), + gallery: createIosImageGallery(() => locale), + pdf: false, + file: false, + pluginEmbed: false, + table: { table: { resizable: false } }, + markdown: true, }), EdgeEverLink, NativeAttachmentMetadata, - TaskList, - TaskItem.configure({ nested: true }), - MergeDivider, - ...createEdgeEverMathematics(), CodeBlock.configure({ languageClassPrefix: "language-", }), - createIosImageGallery(() => locale), - createEdgeEverImageExtension(), - TableKit.configure({ - table: { resizable: false }, - }), ...createNativeUnsupportedContentExtensions(), Placeholder.configure({ placeholder, }), - Markdown.configure({ - markedOptions: { gfm: true }, - }), ]; } diff --git a/apps/mobile/Gemfile b/apps/ios/Gemfile similarity index 100% rename from apps/mobile/Gemfile rename to apps/ios/Gemfile diff --git a/apps/mobile/Gemfile.lock b/apps/ios/Gemfile.lock similarity index 100% rename from apps/mobile/Gemfile.lock rename to apps/ios/Gemfile.lock diff --git a/apps/ios/fastlane/Fastfile b/apps/ios/fastlane/Fastfile index 178fb348..5833050c 100644 --- a/apps/ios/fastlane/Fastfile +++ b/apps/ios/fastlane/Fastfile @@ -7,11 +7,11 @@ platform :ios do lane :submit_review do version = ENV.fetch("APP_STORE_VERSION") build_number = ENV.fetch("APP_STORE_BUILD_NUMBER") - release_notes = { - "zh-Hans" => ENV.fetch("APP_STORE_RELEASE_NOTES_ZH"), - "en-US" => ENV.fetch("APP_STORE_RELEASE_NOTES_EN") - } - if ENV["APP_STORE_RELEASE_NOTES_JA"].to_s.strip != "" + notes_zh = ENV["APP_STORE_RELEASE_NOTES_ZH"].to_s.strip + notes_en = ENV["APP_STORE_RELEASE_NOTES_EN"].to_s.strip + has_notes = notes_zh != "" && notes_en != "" + release_notes = has_notes ? { "zh-Hans" => notes_zh, "en-US" => notes_en } : nil + if has_notes && ENV["APP_STORE_RELEASE_NOTES_JA"].to_s.strip != "" release_notes["ja"] = ENV.fetch("APP_STORE_RELEASE_NOTES_JA") end @@ -33,7 +33,7 @@ platform :ios do phased_release: false, force: true, skip_binary_upload: true, - skip_metadata: false, + skip_metadata: !has_notes, skip_screenshots: true, release_notes: release_notes, run_precheck_before_submit: false, diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 4037057e..4862d5f4 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -75,12 +75,7 @@ "expo-sharing", { "ios": { - "enabled": true, - "activationRule": { - "supportsText": true, - "supportsWebPageWithMaxCount": 1, - "supportsWebUrlWithMaxCount": 1 - } + "enabled": false }, "android": { "enabled": true, diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index 696ca310..b2a95029 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -17,18 +17,10 @@ }, "production": { "autoIncrement": true, - "channel": "production", - "ios": { - "image": "latest" - } + "channel": "production" } }, "submit": { - "production": { - "ios": { - "ascAppId": "6792625631" - } - }, "store-internal": { "android": { "track": "internal", @@ -52,11 +44,6 @@ "track": "production", "releaseStatus": "completed" } - }, - "store-ios": { - "ios": { - "ascAppId": "6792625631" - } } } } diff --git a/apps/mobile/fastlane/Fastfile b/apps/mobile/fastlane/Fastfile deleted file mode 100644 index 1a0f4b26..00000000 --- a/apps/mobile/fastlane/Fastfile +++ /dev/null @@ -1,34 +0,0 @@ -opt_out_usage - -default_platform(:ios) - -platform :ios do - desc "Submit an uploaded EAS build to App Review and release it automatically after approval" - lane :submit_review do - version = ENV.fetch("APP_STORE_VERSION") - build_number = ENV.fetch("APP_STORE_BUILD_NUMBER") - - api_key = app_store_connect_api_key( - key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"), - issuer_id: ENV.fetch("APP_STORE_CONNECT_API_ISSUER_ID"), - key_content: ENV.fetch("APP_STORE_CONNECT_API_KEY_P8_BASE64"), - is_key_content_base64: true, - in_house: false - ) - - deliver( - api_key: api_key, - app_identifier: "org.edgeever.mobile", - app_version: version, - build_number: build_number, - submit_for_review: true, - automatic_release: true, - phased_release: false, - force: true, - skip_binary_upload: true, - skip_metadata: true, - skip_screenshots: true, - precheck_include_in_app_purchases: false - ) - end -end diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 81932f63..6781a9eb 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -7,7 +7,6 @@ "scripts": { "dev": "bun ../../scripts/mobile-dev.mjs", "android": "bun ../../scripts/mobile-dev.mjs run:android", - "ios": "expo run:ios", "web": "expo start --web", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 484096a1..7e1b855c 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -5,10 +5,7 @@ import { Graph } from "@antv/x6"; import Image from "@tiptap/extension-image"; import CodeBlock from "@tiptap/extension-code-block"; import Placeholder from "@tiptap/extension-placeholder"; -import { TaskItem, TaskList } from "@tiptap/extension-list"; -import { TableKit } from "@tiptap/extension-table"; import { EditorContent, Extension, useEditor, useEditorState, type Editor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; import { EdgeEverLink } from "@edgeever/shared/editor-link"; import * as Clipboard from "expo-clipboard"; import { @@ -30,7 +27,7 @@ import { isAiSelectionSnapshotCurrent, markdownToDoc, MEMO_CONTENT_STYLE, - MergeDivider, + createEdgeEverDocumentExtensions, NativeAttachmentMetadata, normalizeAiSelectionReplacement, prepareNativeEditorContent, @@ -769,22 +766,22 @@ function LocalTiptapEditorImpl(props: LocalTiptapEditorProps) { // the Android bridge retry raced each other and could leave the WebView stuck. autofocus: false, extensions: [ - StarterKit.configure({ codeBlock: false, link: false }), + ...createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + starterKit: { codeBlock: false, link: false }, + image: protectedImageExtension, + gallery: ImageGallery.extend({ + addNodeView() { return createNativeImageGalleryView(() => props.locale); }, + }), + pdf: false, + file: false, + pluginEmbed: false, + table: { table: { renderWrapper: true } }, + }), EdgeEverLink.configure({ openOnClick: false }), NativeAttachmentMetadata, - TaskList, - TaskItem.configure({ nested: true }), - MergeDivider, - ...createEdgeEverMathematics(), mermaidCodeBlockExtension, - ImageGallery.extend({ - addNodeView() { return createNativeImageGalleryView(() => props.locale); }, - }), - protectedImageExtension, searchHighlightExtension, - TableKit.configure({ - table: { renderWrapper: true }, - }), ...createNativeUnsupportedContentExtensions(), Placeholder.configure({ placeholder: () => isViewerRef.current ? "" : getMobileEditorPlaceholder(props.locale), diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index e9209ef9..1fce7e3e 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -2,10 +2,7 @@ import { useRef, useState, useEffect, useCallback, useMemo, lazy, Suspense, type import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import { useEditor, EditorContent, type Editor } from "@tiptap/react"; import { BubbleMenu } from "@tiptap/react/menus"; -import StarterKit from "@tiptap/starter-kit"; -import { TaskItem, TaskList } from "@tiptap/extension-list"; import Placeholder from "@tiptap/extension-placeholder"; -import { TableKit } from "@tiptap/extension-table"; import { useTranslation } from "react-i18next"; import * as m from "motion/react-m"; import "katex/dist/katex.min.css"; @@ -110,10 +107,10 @@ import { cn, formatDateTime, parseTagsText } from "@/lib/utils"; import { EDITOR_CONTENT_MAX_WIDTH, EDITOR_CONTENT_MAX_WIDTH_COLLAPSED } from "@/lib/workspace-ui"; import { countMemoCharacters, + createEdgeEverDocumentExtensions, docToMarkdown, MEMO_CONTENT_STYLE, markdownToDoc, - MergeDivider, normalizeImageGalleries, PLUGIN_EMBED_NODE_TYPE, pluginEmbedToMarkdown, @@ -181,7 +178,6 @@ import { AI_SPACE_SHORTCUT_CHANGED_EVENT, readAiSpaceShortcutPreference, } from "@/lib/ai-space-shortcut-preference"; -import { isBrowserOffline } from "@/lib/network-status"; import { EDITOR_LINK_OPEN_MODE_CHANGED_EVENT, getStoredEditorLinkOpenMode, @@ -259,161 +255,26 @@ import { type ResourceMenuTarget, } from "./editor/useEditorResourceActions"; import { removeAttachmentAt, renameAttachmentAt } from "./editor/attachment-editor-range"; - -const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); -const MOBILE_EDITOR_QUERY = "(max-width: 639px)"; -const MOBILE_DRAFT_PERSIST_DELAY_MS = 800; - -const createLocalEditSession = (memo: MemoDetail): MemoEditSession => ({ - id: `local-edit:${memo.id}`, - memoId: memo.id, - baseRevision: memo.revision, - baseContentHash: memo.contentHash, - expiresAt: new Date(Date.now() + 86_400_000).toISOString(), -}); - -const requiresLocalEditSession = (memo: MemoDetail) => - isDesktopResourceRuntime() || - isLocalMemoId(memo.id) || - isBrowserOffline(); - -type AiSelectionContext = { - kind: "markdown" | "plain"; - from: number; - to: number; - contentMarkdown: string; -} | { - kind: "rich"; - from: number; - to: number; - contentMarkdown: string; - isInline: boolean; -}; - -type AiInsertionTarget = { - kind: "markdown" | "plain" | "rich"; - position: number; -}; - -const getNoteLinkFromEventTarget = (target: EventTarget | null) => - target instanceof Element - ? target.closest('a.edgeever-note-link, a[href^="#memo="]') - : null; - -/** Any navigable editor link (external or note). Attachment chips have their own menu. */ -const getEditorNavigableLinkFromEventTarget = (target: EventTarget | null) => { - if (!(target instanceof Element)) { - return null; - } - - const link = target.closest("a[href]"); - if (!link || getAttachmentLinkFromEventTarget(link)) { - return null; - } - - return link; -}; - -const getNoteLinkHintPosition = (link: HTMLAnchorElement): NoteLinkHintPosition => { - const rect = link.getBoundingClientRect(); - const placement = rect.top < 48 ? "below" : "above"; - - return { - left: Math.min(Math.max(rect.left + rect.width / 2, 12), window.innerWidth - 12), - top: placement === "above" ? rect.top - 8 : rect.bottom + 8, - placement, - }; -}; - -type MobilePlainTextElement = HTMLTextAreaElement | HTMLDivElement; - -const isEditorReady = (editor: Editor | null | undefined): editor is Editor => - Boolean(editor && !editor.isDestroyed && (editor as { extensionManager?: unknown }).extensionManager); - -const getMobilePlainTextElementValue = (element: MobilePlainTextElement | null) => { - if (!element) { - return ""; - } - - return "value" in element ? element.value : element.innerText; -}; - -const setMobilePlainTextElementValue = (element: MobilePlainTextElement | null, value: string) => { - if (!element) { - return; - } - - if ("value" in element) { - element.value = value; - return; - } - - if (element.innerText !== value) { - element.textContent = value; - } -}; - -const focusMobilePlainTextElement = (element: MobilePlainTextElement | null) => { - if (!element) { - return; - } - - element.focus({ preventScroll: true }); - - if ("setSelectionRange" in element) { - element.setSelectionRange(element.value.length, element.value.length); - return; - } - - if (typeof document === "undefined" || typeof window === "undefined") { - return; - } - - const range = document.createRange(); - range.selectNodeContents(element); - range.collapse(false); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); -}; - -const getResourceFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => { - if (!dataTransfer) { - return []; - } - - const fileItems = Array.from(dataTransfer.items ?? []) - .filter((item) => item.kind === "file") - .map((item) => item.getAsFile()) - .filter((file): file is File => Boolean(file)); - const files = fileItems.length > 0 ? fileItems : Array.from(dataTransfer.files ?? []); - - return files.filter((file) => file.size > 0); -}; - -const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => { - if (status === "conflict") { - return "conflict"; - } - if (status === "syncing") { - return "saving"; - } - return "queued"; -}; - -class MemoSaveRequestError extends Error { - originalError: unknown; - payload: MemoUpdateSyncPayload; - tagsText: string; - - constructor(originalError: unknown, payload: MemoUpdateSyncPayload, tagsText: string) { - super(originalError instanceof Error ? originalError.message : "Memo save failed"); - this.name = "MemoSaveRequestError"; - this.originalError = originalError; - this.payload = payload; - this.tagsText = tagsText; - } -} +import { + createLocalEditSession, + focusMobilePlainTextElement, + getEditorNavigableLinkFromEventTarget, + getMobilePlainTextElementValue, + getNoteLinkFromEventTarget, + getNoteLinkHintPosition, + getResourceFilesFromDataTransfer, + isEditorReady, + MemoSaveRequestError, + MOBILE_DRAFT_PERSIST_DELAY_MS, + MOBILE_EDITOR_QUERY, + requiresLocalEditSession, + setMobilePlainTextElementValue, + SUPPORTED_PASTE_IMAGE_TYPES, + syncStatusToSaveState, + type AiInsertionTarget, + type AiSelectionContext, + type MobilePlainTextElement, +} from "./editor/editor-pane-helpers"; type EditorPaneProps = { memo: MemoDetail | null; @@ -1183,30 +1044,25 @@ const RichEditorPane = ({ const inlineFieldExtension = useMemo(() => createInlineFieldExtension(i18n.language), [i18n.language]); const editor = useEditor({ extensions: [ - StarterKit.configure({ - codeBlock: false, - link: false, + ...createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + starterKit: { codeBlock: false, link: false }, + image: false, + gallery: EditableImageGallery, + pdf: PdfAttachment, + file: FileAttachment, + pluginEmbed: pluginEmbedExtension, + table: { table: { renderWrapper: true } }, }), EdgeEverLink.configure({ openOnClick: false }), - TaskList, - TaskItem.configure({ nested: true }), inlineFieldExtension, EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }), - MergeDivider, - pluginEmbedExtension, - PdfAttachment, - FileAttachment, - ...createEdgeEverMathematics(), ThemeBlock, - EditableImageGallery, ResizableImage.configure({ allowBase64: false, inline: false, }), ImageUploadPlaceholderExtension, - TableKit.configure({ - table: { renderWrapper: true }, - }), Placeholder.configure({ placeholder: () => aiSpaceShortcutEnabledRef.current ? t("editor.placeholder") diff --git a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx index 4fedc5d7..84c7b83d 100644 --- a/apps/web/src/components/MobileStandaloneTiptapEditor.tsx +++ b/apps/web/src/components/MobileStandaloneTiptapEditor.tsx @@ -2,15 +2,12 @@ import "katex/dist/katex.min.css"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { EditorContent, useEditor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; import { PdfAttachment } from "@/components/editor/PdfAttachment"; import { FileAttachment } from "@/components/editor/FileAttachment"; import Image from "@tiptap/extension-image"; -import { TaskItem, TaskList } from "@tiptap/extension-list"; import { mergeAttributes } from "@tiptap/core"; import Placeholder from "@tiptap/extension-placeholder"; -import { TableKit } from "@tiptap/extension-table"; -import { createExcerpt, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, ImageGallery, isPdfAttachment, MergeDivider, PluginEmbed, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared"; +import { createExcerpt, createEdgeEverDocumentExtensions, docToMarkdown, docToText, emptyDoc, getImageReferrerPolicy, isPdfAttachment, type MemoDetail, type MemoEditSession, type Notebook, type TagSummary, type TiptapDoc } from "@edgeever/shared"; import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; import { getMobileEditorInputAttributes, getMobileEditorPlaceholder } from "@edgeever/shared/mobile-editor"; import { EdgeEverLink } from "@edgeever/shared/editor-link"; @@ -199,25 +196,20 @@ export const MobileStandaloneTiptapEditor = ({ const inlineFieldExtension = useMemo(() => createInlineFieldExtension(locale), [locale]); const editor = useEditor({ extensions: [ - StarterKit.configure({ link: false }), + ...createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + starterKit: { link: false }, + image: ProtectedExternalImage.configure({ + allowBase64: false, + inline: false, + }), + pdf: PdfAttachment, + file: FileAttachment, + table: { table: { renderWrapper: true } }, + }), EdgeEverLink, - PdfAttachment, - FileAttachment, - TaskList, - TaskItem.configure({ nested: true }), inlineFieldExtension, - MergeDivider, - PluginEmbed, - ...createEdgeEverMathematics(), ThemeBlock, - ImageGallery, - ProtectedExternalImage.configure({ - allowBase64: false, - inline: false, - }), - TableKit.configure({ - table: { renderWrapper: true }, - }), Placeholder.configure({ placeholder: getMobileEditorPlaceholder(locale), }), diff --git a/apps/web/src/components/PublicSharePage.tsx b/apps/web/src/components/PublicSharePage.tsx index 06d2cc4b..ea5e15d5 100644 --- a/apps/web/src/components/PublicSharePage.tsx +++ b/apps/web/src/components/PublicSharePage.tsx @@ -1,10 +1,7 @@ import "katex/dist/katex.min.css"; import { Node, mergeAttributes } from "@tiptap/core"; import Image from "@tiptap/extension-image"; -import { TaskItem, TaskList } from "@tiptap/extension-list"; -import { TableKit } from "@tiptap/extension-table"; import { EditorContent, useEditor } from "@tiptap/react"; -import StarterKit from "@tiptap/starter-kit"; import { useQuery } from "@tanstack/react-query"; import { Clock3, FileText, LoaderCircle, ShieldCheck } from "lucide-react"; import { lazy, Suspense, useEffect, useMemo } from "react"; @@ -17,9 +14,7 @@ import { resolvePublicShareBody } from "@/lib/public-share-body"; import { parseImageWidth, getImageReferrerPolicy, - ImageGallery, - MergeDivider, - PluginEmbed, + createEdgeEverDocumentExtensions, type PublicMemoShare, } from "@edgeever/shared"; import { createEdgeEverMathematics } from "@edgeever/shared/mathematics"; @@ -81,19 +76,16 @@ const SharedThemeBlock = Node.create({ const SharedRichText = ({ content }: { content: PublicMemoShare["contentJson"] }) => { const editor = useEditor({ extensions: [ - StarterKit.configure({ codeBlock: false, link: { openOnClick: true } }), - TaskList, - TaskItem.configure({ nested: true }), + ...createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMathematics(), + starterKit: { codeBlock: false, link: { openOnClick: true } }, + image: SharedImage.configure({ allowBase64: false, inline: false }), + pdf: PdfAttachment, + file: FileAttachment, + table: { table: { renderWrapper: true } }, + }), EdgeEverCodeBlock.configure({ lowlight: codeBlockLowlight, defaultLanguage: "plaintext" }), - MergeDivider, - PluginEmbed, - PdfAttachment, - FileAttachment, - ...createEdgeEverMathematics(), SharedThemeBlock, - ImageGallery, - SharedImage.configure({ allowBase64: false, inline: false }), - TableKit.configure({ table: { renderWrapper: true } }), ], content, editable: false, diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 6559e93f..55adbc24 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -13,21 +13,14 @@ import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from "react"; -import { useInfiniteQuery, useQuery, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import type { PluginPanelOpenOptions } from "@edgeever/plugin-api"; -import { Home, Search, UserRound, Plus, ChevronDown, ChevronRight, RefreshCw, X, FileText, Network, Workflow, Boxes } from "lucide-react"; +import { RefreshCw, X } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as m from "motion/react-m"; import { Button } from "@/components/ui/button"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer"; import { MemoListPane, MemoSelectionActionBar } from "./MemoListPane"; +import { MobileBottomNav, MobileNotebookPicker } from "./WorkspaceMobileChrome"; import { QuickMemoSwitcher } from "./QuickMemoSwitcher"; import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs"; import { PluginPanelDialog } from "./plugins/PluginPanelDialog"; @@ -52,7 +45,7 @@ import { } from "@/lib/mobile-editor"; import { cn } from "@/lib/utils"; import { isBrowserOffline, isBrowserOnline } from "@/lib/network-status"; -import { createDefaultDiagramDocument, getDiagramSummary, createExcerpt, diagramFallbackMarkdown, docToText, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, resolveMemoContentDoc, serializeDiagramDocument, type DiagramKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; +import { createDefaultDiagramDocument, diagramFallbackMarkdown, getNotebookDescendantIds, markdownToDoc, parseDiagramDocument, serializeDiagramDocument, type DiagramKind, type Notebook, type AuthUser, type MemoSummary, type MemoDetail, type MemoTemplate as SavedMemoTemplate } from "@edgeever/shared"; import { toggleMobileMemoSelection } from "@edgeever/shared/mobile-ui"; import type { Pane, @@ -61,7 +54,6 @@ import type { NotebookNameDialogState, AppNoticeDialogState, MobileBottomNavItem, - NotebookNode, NotebookDropPosition, NotebookMoveOption, MemoFilterMode, @@ -77,17 +69,31 @@ import { getSearchShortcutScope, getShortcutActionForEvent, getNotebookDropSortOrder, - buildNotebookTree, - notebookTreeContainsId, - getNotebookAncestorIds, - getExpandableNotebookIds, - filterNotebookTree, getNotebookMoveOptions, getMemoIdsNeedingMove, resolveSelectionMoveTargetNotebookId, } from "@/lib/app-helpers"; import { useBrowserBackLayer } from "@/lib/app-hooks"; import { updateMemoSummaryInLists, type MemoListQueryData } from "@/lib/memo-list-cache"; +import { + cacheMemoDetail, + clearTrashMemoLists, + collectMemoSummariesFromCache, + decrementNotebookMemoCounts, + getAdjacentMemoIdAfterRemoval, + memoDetailQueryKey, + memoToSummary, + removeMemoSummariesFromLists, + type ListNotebooksQueryData, +} from "@/lib/workspace-memo-cache"; +import { + getVerticalScrollContainer, + isDesktopViewport, + isStandaloneApp, + PULL_TO_REFRESH_MAX_PX, + PULL_TO_REFRESH_TRIGGER_PX, + runWorkspaceViewTransition, +} from "@/lib/workspace-shell"; import { shouldAcceptRemoteMemoDetail } from "@/lib/memo-detail-freshness"; import { clearLocalScope, @@ -121,50 +127,6 @@ import { compressImageForUpload } from "@/lib/image-compression"; import { createScreenshotMemo, screenshotFileFromImportPayload } from "@/lib/screenshot-import"; import { isDesktopResourceRuntime, stageDesktopResource, toDesktopResourceUrl } from "@/lib/desktop-resources"; -const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; -const PULL_TO_REFRESH_TRIGGER_PX = 72; -const PULL_TO_REFRESH_MAX_PX = 96; - -type ViewTransitionDocument = Document & { - startViewTransition?: (update: () => void) => unknown; -}; - -const runWorkspaceViewTransition = (update: () => void) => { - const viewTransitionDocument = document as ViewTransitionDocument; - - if ( - !viewTransitionDocument.startViewTransition || - window.matchMedia("(prefers-reduced-motion: reduce)").matches - ) { - update(); - return; - } - - viewTransitionDocument.startViewTransition(update); -}; - -const isStandaloneApp = () => - window.matchMedia("(display-mode: standalone)").matches || - window.matchMedia("(display-mode: fullscreen)").matches || - Boolean((navigator as Navigator & { standalone?: boolean }).standalone); - -const getVerticalScrollContainer = (target: EventTarget | null) => { - let element = target instanceof HTMLElement ? target : null; - - while (element && element !== document.body) { - const style = window.getComputedStyle(element); - const canScroll = /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight; - - if (canScroll) { - return element; - } - - element = element.parentElement; - } - - return null; -}; - const EditorPane = lazy(() => import("./EditorPane").then((module) => ({ default: module.EditorPane }))); const DiagramEditorPane = lazy(() => import("./DiagramEditorPane")); const AssetsPane = lazy(() => import("./AssetsPane").then((module) => ({ default: module.AssetsPane }))); @@ -189,12 +151,6 @@ const PaneLoadingFallback = ({ label = "Loading" }: { label?: string }) => ( ); -const memoDetailQueryKey = (memoId: string, view: MemoView) => ["memo", memoId, view] as const; - -type ListNotebooksQueryData = { - notebooks: Notebook[]; -}; - type MemoDeleteOptimisticContext = { previousMemoLists: Array<[readonly unknown[], MemoListQueryData | undefined]>; previousMemoDetails: Array<[readonly unknown[], { memo: MemoDetail } | undefined]>; @@ -210,487 +166,6 @@ type EmptyTrashOptimisticContext = { previousSelectedMemoId: string | null; }; -const memoToSummary = (memo: MemoDetail): MemoSummary => ({ - id: memo.id, - notebookId: memo.notebookId, - title: memo.title, - excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))), - ...getDiagramSummary(memo.contentMarkdown), - tags: memo.tags, - isPinned: memo.isPinned, - isArchived: memo.isArchived, - isDeleted: memo.isDeleted, - revision: memo.revision, - createdAt: memo.createdAt, - updatedAt: memo.updatedAt, - deletedAt: memo.deletedAt, -}); - -const cacheMemoDetail = (queryClient: QueryClient, memo: MemoDetail, view: MemoView = memo.isDeleted ? "trash" : "notebook") => { - queryClient.setQueryData(memoDetailQueryKey(memo.id, view), { memo }); -}; - -const collectMemoSummariesFromCache = (queryClient: QueryClient, memoIds: Set) => { - const summaries = new Map(); - - for (const [, current] of queryClient.getQueriesData({ queryKey: ["memos"] })) { - for (const page of current?.pages ?? []) { - for (const memo of page.memos) { - if (memoIds.has(memo.id) && !summaries.has(memo.id)) { - summaries.set(memo.id, memo); - } - } - } - } - - for (const [, current] of queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] })) { - if (current?.memo && memoIds.has(current.memo.id) && !summaries.has(current.memo.id)) { - summaries.set(current.memo.id, memoToSummary(current.memo)); - } - } - - return Array.from(summaries.values()); -}; - -const removeMemoSummariesFromLists = (queryClient: QueryClient, memoIds: Set) => { - queryClient.setQueriesData({ queryKey: ["memos"] }, (current) => { - if (!current) { - return current; - } - - let changed = false; - const pages = current.pages.map((page) => { - const memos = page.memos.filter((memo) => !memoIds.has(memo.id)); - - if (memos.length === page.memos.length) { - return page; - } - - changed = true; - return { - ...page, - memos, - totalCount: Math.max(0, page.totalCount - (page.memos.length - memos.length)), - }; - }); - - return changed ? { ...current, pages } : current; - }); -}; - -const clearTrashMemoLists = (queryClient: QueryClient) => { - for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ["memos", "trash"] })) { - if (!current) { - continue; - } - - queryClient.setQueryData(queryKey, { - ...current, - pages: current.pages.map((page) => ({ ...page, memos: [], totalCount: 0, nextCursor: null })), - }); - } -}; - -const decrementNotebookMemoCounts = (queryClient: QueryClient, removedMemos: MemoSummary[]) => { - if (removedMemos.length === 0) { - return; - } - - const countsByNotebook = new Map(); - - for (const memo of removedMemos) { - if (memo.isDeleted) { - continue; - } - - countsByNotebook.set(memo.notebookId, (countsByNotebook.get(memo.notebookId) ?? 0) + 1); - } - - if (countsByNotebook.size === 0) { - return; - } - - queryClient.setQueryData(["notebooks"], (current) => - current - ? { - notebooks: current.notebooks.map((notebook) => { - const removedCount = countsByNotebook.get(notebook.id) ?? 0; - return removedCount > 0 ? { ...notebook, memoCount: Math.max(0, notebook.memoCount - removedCount) } : notebook; - }), - } - : current - ); -}; - -const getAdjacentMemoIdAfterRemoval = (memos: MemoSummary[], removedMemoIds: Set, anchorMemoId: string) => { - const anchorIndex = memos.findIndex((memo) => memo.id === anchorMemoId); - - if (anchorIndex < 0) { - return null; - } - - for (let index = anchorIndex + 1; index < memos.length; index++) { - const memoId = memos[index]?.id; - if (memoId && !removedMemoIds.has(memoId)) { - return memoId; - } - } - - for (let index = anchorIndex - 1; index >= 0; index--) { - const memoId = memos[index]?.id; - if (memoId && !removedMemoIds.has(memoId)) { - return memoId; - } - } - - return null; -}; - -const MobileBottomNavButton = ({ - active = false, - icon, - label, - onClick, -}: { - active?: boolean; - icon: ReactNode; - label: string; - onClick: () => void; -}) => ( - -); - -const MobileBottomNav = ({ - activeItem, - canCreateMemo, - isCreating, - onCreateMemo, - onHome, - onOpenSettings, -}: { - activeItem: MobileBottomNavItem; - canCreateMemo: boolean; - isCreating: boolean; - onCreateMemo: (kind?: DiagramKind) => void; - onHome: () => void; - onOpenSettings: () => void; -}) => { - const { t } = useTranslation(); - const createMemoLabel = !canCreateMemo ? t("nav.createDisabled") : isCreating ? t("nav.creating") : t("nav.createMemo"); - - return ( - - ); -}; - -const MobileNotebookPicker = ({ - currentLabel, - notebooks, - selectedNotebookId, - onClose, - onSelectAll, - onSelect, -}: { - currentLabel?: string; - notebooks: Notebook[]; - selectedNotebookId: string | null; - onClose: () => void; - onSelectAll: () => void; - onSelect: (notebookId: string) => void; -}) => { - const { t, i18n } = useTranslation(); - const listRef = useRef(null); - const [notebookSearch, setNotebookSearch] = useState(""); - const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); - const filteredTree = useMemo(() => filterNotebookTree(tree, notebookSearch), [notebookSearch, tree]); - const selectedAncestorIds = useMemo( - () => (selectedNotebookId ? getNotebookAncestorIds(tree, selectedNotebookId) : []), - [selectedNotebookId, tree] - ); - const expandableNotebookIds = useMemo(() => getExpandableNotebookIds(tree), [tree]); - const [expandedNotebookIds, setExpandedNotebookIds] = useState>(() => new Set(selectedAncestorIds)); - const allSelected = !currentLabel && selectedNotebookId === null; - const selectedNotebookName = - currentLabel ?? (allSelected ? t("mobileNotebookPicker.allMemos") : notebooks.find((item) => item.id === selectedNotebookId)?.name ?? t("mobileNotebookPicker.notebookFallback")); - const searchQuery = notebookSearch.trim(); - const searchActive = Boolean(searchQuery); - const allNotebookBranchesExpanded = - expandableNotebookIds.length > 0 && expandableNotebookIds.every((notebookId) => expandedNotebookIds.has(notebookId)); - - useEffect(() => { - if (selectedAncestorIds.length === 0) { - return; - } - setExpandedNotebookIds((current) => { - const next = new Set(current); - for (const notebookId of selectedAncestorIds) { - next.add(notebookId); - } - return next; - }); - }, [selectedAncestorIds]); - - useEffect(() => { - if (searchActive) { - return; - } - window.setTimeout(() => { - const listNode = listRef.current; - const targetNotebookId = selectedNotebookId ?? "__all__"; - const selectedNode = listNode?.querySelector(`[data-mobile-notebook-id="${CSS.escape(targetNotebookId)}"]`); - selectedNode?.scrollIntoView({ block: "center" }); - }, 0); - }, [searchActive, selectedNotebookId]); - - const handleToggleNotebookExpanded = (notebookId: string) => { - setExpandedNotebookIds((current) => { - const next = new Set(current); - if (next.has(notebookId)) { - next.delete(notebookId); - } else { - next.add(notebookId); - } - return next; - }); - }; - - const handleToggleAllNotebookBranches = () => { - setExpandedNotebookIds(allNotebookBranchesExpanded ? new Set() : new Set(expandableNotebookIds)); - }; - - return ( - { if (!open) onClose(); }}> - -
- - {t("mobileNotebookPicker.title")} - {t("mobileNotebookPicker.current", { name: selectedNotebookName })} - - -
-
-
- - setNotebookSearch(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Escape" && notebookSearch) { - event.preventDefault(); - setNotebookSearch(""); - } - }} - /> - {notebookSearch && ( - - )} -
-
-
- - {filteredTree.length > 0 ? ( - <> -
- {searchActive ? t("mobileNotebookPicker.matchedNotebooks") : t("mobileNotebookPicker.notebooks")} - {!searchActive && expandableNotebookIds.length > 0 && ( - - )} -
- {filteredTree.map((node) => ( - - ))} - - ) : ( -
-
- {searchQuery ? t("mobileNotebookPicker.noSearchResult", { query: searchQuery }) : t("mobileNotebookPicker.noNotebook")} -
- {searchQuery && ( - - )} -
- )} -
-
-
- ); -}; - -const MobileNotebookPickerItem = ({ - node, - depth, - expandedNotebookIds, - searchActive, - selectedNotebookId, - onSelect, - onToggleExpanded, -}: { - node: NotebookNode; - depth: number; - expandedNotebookIds: Set; - searchActive: boolean; - selectedNotebookId: string | null; - onSelect: (notebookId: string) => void; - onToggleExpanded: (notebookId: string) => void; -}) => { - const { t } = useTranslation(); - const selected = node.id === selectedNotebookId; - const hasChildren = node.children.length > 0; - const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; - const expanded = searchActive || expandedNotebookIds.has(node.id); - - return ( -
-
- {hasChildren ? ( - - ) : ( -
- {hasChildren && expanded ? ( -
- {node.children.map((child) => ( - - ))} -
- ) : null} -
- ); -}; - export const WorkspaceApp = ({ authRequired, demoMode, diff --git a/apps/web/src/components/WorkspaceMobileChrome.tsx b/apps/web/src/components/WorkspaceMobileChrome.tsx new file mode 100644 index 00000000..b759ecd8 --- /dev/null +++ b/apps/web/src/components/WorkspaceMobileChrome.tsx @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { Boxes, ChevronDown, ChevronRight, FileText, Home, Network, Plus, Search, UserRound, Workflow, X } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { cn } from "@/lib/utils"; +import type { DiagramKind, Notebook } from "@edgeever/shared"; +import type { MobileBottomNavItem, NotebookNode } from "@/lib/app-helpers"; +import { + buildNotebookTree, + filterNotebookTree, + getExpandableNotebookIds, + getNotebookAncestorIds, + notebookTreeContainsId, +} from "@/lib/app-helpers"; + +const MobileBottomNavButton = ({ + active = false, + icon, + label, + onClick, +}: { + active?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; +}) => ( + +); + +export const MobileBottomNav = ({ + activeItem, + canCreateMemo, + isCreating, + onCreateMemo, + onHome, + onOpenSettings, +}: { + activeItem: MobileBottomNavItem; + canCreateMemo: boolean; + isCreating: boolean; + onCreateMemo: (kind?: DiagramKind) => void; + onHome: () => void; + onOpenSettings: () => void; +}) => { + const { t } = useTranslation(); + const createMemoLabel = !canCreateMemo ? t("nav.createDisabled") : isCreating ? t("nav.creating") : t("nav.createMemo"); + + return ( + + ); +}; + +export const MobileNotebookPicker = ({ + currentLabel, + notebooks, + selectedNotebookId, + onClose, + onSelectAll, + onSelect, +}: { + currentLabel?: string; + notebooks: Notebook[]; + selectedNotebookId: string | null; + onClose: () => void; + onSelectAll: () => void; + onSelect: (notebookId: string) => void; +}) => { + const { t } = useTranslation(); + const listRef = useRef(null); + const [notebookSearch, setNotebookSearch] = useState(""); + const tree = useMemo(() => buildNotebookTree(notebooks), [notebooks]); + const filteredTree = useMemo(() => filterNotebookTree(tree, notebookSearch), [notebookSearch, tree]); + const selectedAncestorIds = useMemo( + () => (selectedNotebookId ? getNotebookAncestorIds(tree, selectedNotebookId) : []), + [selectedNotebookId, tree] + ); + const expandableNotebookIds = useMemo(() => getExpandableNotebookIds(tree), [tree]); + const [expandedNotebookIds, setExpandedNotebookIds] = useState>(() => new Set(selectedAncestorIds)); + const allSelected = !currentLabel && selectedNotebookId === null; + const selectedNotebookName = + currentLabel ?? (allSelected ? t("mobileNotebookPicker.allMemos") : notebooks.find((item) => item.id === selectedNotebookId)?.name ?? t("mobileNotebookPicker.notebookFallback")); + const searchQuery = notebookSearch.trim(); + const searchActive = Boolean(searchQuery); + const allNotebookBranchesExpanded = + expandableNotebookIds.length > 0 && expandableNotebookIds.every((notebookId) => expandedNotebookIds.has(notebookId)); + + useEffect(() => { + if (selectedAncestorIds.length === 0) { + return; + } + setExpandedNotebookIds((current) => { + const next = new Set(current); + for (const notebookId of selectedAncestorIds) { + next.add(notebookId); + } + return next; + }); + }, [selectedAncestorIds]); + + useEffect(() => { + if (searchActive) { + return; + } + window.setTimeout(() => { + const listNode = listRef.current; + const targetNotebookId = selectedNotebookId ?? "__all__"; + const selectedNode = listNode?.querySelector(`[data-mobile-notebook-id="${CSS.escape(targetNotebookId)}"]`); + selectedNode?.scrollIntoView({ block: "center" }); + }, 0); + }, [searchActive, selectedNotebookId]); + + const handleToggleNotebookExpanded = (notebookId: string) => { + setExpandedNotebookIds((current) => { + const next = new Set(current); + if (next.has(notebookId)) { + next.delete(notebookId); + } else { + next.add(notebookId); + } + return next; + }); + }; + + const handleToggleAllNotebookBranches = () => { + setExpandedNotebookIds(allNotebookBranchesExpanded ? new Set() : new Set(expandableNotebookIds)); + }; + + return ( + { if (!open) onClose(); }}> + +
+ + {t("mobileNotebookPicker.title")} + {t("mobileNotebookPicker.current", { name: selectedNotebookName })} + + +
+
+
+ + setNotebookSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && notebookSearch) { + event.preventDefault(); + setNotebookSearch(""); + } + }} + /> + {notebookSearch && ( + + )} +
+
+
+ + {filteredTree.length > 0 ? ( + <> +
+ {searchActive ? t("mobileNotebookPicker.matchedNotebooks") : t("mobileNotebookPicker.notebooks")} + {!searchActive && expandableNotebookIds.length > 0 && ( + + )} +
+ {filteredTree.map((node) => ( + + ))} + + ) : ( +
+
+ {searchQuery ? t("mobileNotebookPicker.noSearchResult", { query: searchQuery }) : t("mobileNotebookPicker.noNotebook")} +
+ {searchQuery && ( + + )} +
+ )} +
+
+
+ ); +}; + +const MobileNotebookPickerItem = ({ + node, + depth, + expandedNotebookIds, + searchActive, + selectedNotebookId, + onSelect, + onToggleExpanded, +}: { + node: NotebookNode; + depth: number; + expandedNotebookIds: Set; + searchActive: boolean; + selectedNotebookId: string | null; + onSelect: (notebookId: string) => void; + onToggleExpanded: (notebookId: string) => void; +}) => { + const { t } = useTranslation(); + const selected = node.id === selectedNotebookId; + const hasChildren = node.children.length > 0; + const hasSelectedDescendant = selectedNotebookId ? notebookTreeContainsId(node.children, selectedNotebookId) : false; + const expanded = searchActive || expandedNotebookIds.has(node.id); + + return ( +
+
+ {hasChildren ? ( + + ) : ( +
+ {hasChildren && expanded ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null} +
+ ); +}; diff --git a/apps/web/src/components/editor/editor-pane-helpers.ts b/apps/web/src/components/editor/editor-pane-helpers.ts new file mode 100644 index 00000000..9a6035f7 --- /dev/null +++ b/apps/web/src/components/editor/editor-pane-helpers.ts @@ -0,0 +1,163 @@ +import type { Editor } from "@tiptap/react"; +import type { MemoDetail, MemoEditSession } from "@edgeever/shared"; +import { isDesktopResourceRuntime } from "@/lib/desktop-resources"; +import { isBrowserOffline } from "@/lib/network-status"; +import { isLocalMemoId } from "@/lib/local-mirror"; +import type { MemoUpdateSyncPayload } from "@/lib/local-db"; +import { getAttachmentLinkFromEventTarget } from "./attachment-resource-menu"; +import type { NoteLinkHintPosition } from "./EditorPaneChrome"; + +export const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]); +export const MOBILE_EDITOR_QUERY = "(max-width: 639px)"; +export const MOBILE_DRAFT_PERSIST_DELAY_MS = 800; + +export const createLocalEditSession = (memo: MemoDetail): MemoEditSession => ({ + id: `local-edit:${memo.id}`, + memoId: memo.id, + baseRevision: memo.revision, + baseContentHash: memo.contentHash, + expiresAt: new Date(Date.now() + 86_400_000).toISOString(), +}); + +export const requiresLocalEditSession = (memo: MemoDetail) => + isDesktopResourceRuntime() || + isLocalMemoId(memo.id) || + isBrowserOffline(); + +export type AiSelectionContext = { + kind: "markdown" | "plain"; + from: number; + to: number; + contentMarkdown: string; +} | { + kind: "rich"; + from: number; + to: number; + contentMarkdown: string; + isInline: boolean; +}; + +export type AiInsertionTarget = { + kind: "markdown" | "plain" | "rich"; + position: number; +}; + +export const getNoteLinkFromEventTarget = (target: EventTarget | null) => + target instanceof Element + ? target.closest('a.edgeever-note-link, a[href^="#memo="]') + : null; + +/** Any navigable editor link (external or note). Attachment chips have their own menu. */ +export const getEditorNavigableLinkFromEventTarget = (target: EventTarget | null) => { + if (!(target instanceof Element)) { + return null; + } + + const link = target.closest("a[href]"); + if (!link || getAttachmentLinkFromEventTarget(link)) { + return null; + } + + return link; +}; + +export const getNoteLinkHintPosition = (link: HTMLAnchorElement): NoteLinkHintPosition => { + const rect = link.getBoundingClientRect(); + const placement = rect.top < 48 ? "below" : "above"; + + return { + left: Math.min(Math.max(rect.left + rect.width / 2, 12), window.innerWidth - 12), + top: placement === "above" ? rect.top - 8 : rect.bottom + 8, + placement, + }; +}; + +export type MobilePlainTextElement = HTMLTextAreaElement | HTMLDivElement; + +export const isEditorReady = (editor: Editor | null | undefined): editor is Editor => + Boolean(editor && !editor.isDestroyed && (editor as { extensionManager?: unknown }).extensionManager); + +export const getMobilePlainTextElementValue = (element: MobilePlainTextElement | null) => { + if (!element) { + return ""; + } + + return "value" in element ? element.value : element.innerText; +}; + +export const setMobilePlainTextElementValue = (element: MobilePlainTextElement | null, value: string) => { + if (!element) { + return; + } + + if ("value" in element) { + element.value = value; + return; + } + + if (element.innerText !== value) { + element.textContent = value; + } +}; + +export const focusMobilePlainTextElement = (element: MobilePlainTextElement | null) => { + if (!element) { + return; + } + + element.focus({ preventScroll: true }); + + if ("setSelectionRange" in element) { + element.setSelectionRange(element.value.length, element.value.length); + return; + } + + if (typeof document === "undefined" || typeof window === "undefined") { + return; + } + + const range = document.createRange(); + range.selectNodeContents(element); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); +}; + +export const getResourceFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => { + if (!dataTransfer) { + return []; + } + + const fileItems = Array.from(dataTransfer.items ?? []) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + const files = fileItems.length > 0 ? fileItems : Array.from(dataTransfer.files ?? []); + + return files.filter((file) => file.size > 0); +}; + +export const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => { + if (status === "conflict") { + return "conflict"; + } + if (status === "syncing") { + return "saving"; + } + return "queued"; +}; + +export class MemoSaveRequestError extends Error { + originalError: unknown; + payload: MemoUpdateSyncPayload; + tagsText: string; + + constructor(originalError: unknown, payload: MemoUpdateSyncPayload, tagsText: string) { + super(originalError instanceof Error ? originalError.message : "Memo save failed"); + this.name = "MemoSaveRequestError"; + this.originalError = originalError; + this.payload = payload; + this.tagsText = tagsText; + } +}; diff --git a/apps/web/src/lib/workspace-memo-cache.ts b/apps/web/src/lib/workspace-memo-cache.ts new file mode 100644 index 00000000..f70e3afd --- /dev/null +++ b/apps/web/src/lib/workspace-memo-cache.ts @@ -0,0 +1,154 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { + createExcerpt, + docToText, + getDiagramSummary, + resolveMemoContentDoc, + type MemoDetail, + type MemoSummary, + type Notebook, +} from "@edgeever/shared"; +import type { MemoView } from "@/lib/app-helpers"; +import type { MemoListQueryData } from "@/lib/memo-list-cache"; + +export type ListNotebooksQueryData = { + notebooks: Notebook[]; +}; + +export const memoDetailQueryKey = (memoId: string, view: MemoView) => ["memo", memoId, view] as const; + +export const memoToSummary = (memo: MemoDetail): MemoSummary => ({ + id: memo.id, + notebookId: memo.notebookId, + title: memo.title, + excerpt: memo.excerpt || createExcerpt(memo.contentText || docToText(resolveMemoContentDoc(memo.contentJson, memo.contentMarkdown))), + ...getDiagramSummary(memo.contentMarkdown), + tags: memo.tags, + isPinned: memo.isPinned, + isArchived: memo.isArchived, + isDeleted: memo.isDeleted, + revision: memo.revision, + createdAt: memo.createdAt, + updatedAt: memo.updatedAt, + deletedAt: memo.deletedAt, +}); + +export const cacheMemoDetail = (queryClient: QueryClient, memo: MemoDetail, view: MemoView = memo.isDeleted ? "trash" : "notebook") => { + queryClient.setQueryData(memoDetailQueryKey(memo.id, view), { memo }); +}; + +export const collectMemoSummariesFromCache = (queryClient: QueryClient, memoIds: Set) => { + const summaries = new Map(); + + for (const [, current] of queryClient.getQueriesData({ queryKey: ["memos"] })) { + for (const page of current?.pages ?? []) { + for (const memo of page.memos) { + if (memoIds.has(memo.id) && !summaries.has(memo.id)) { + summaries.set(memo.id, memo); + } + } + } + } + + for (const [, current] of queryClient.getQueriesData<{ memo: MemoDetail }>({ queryKey: ["memo"] })) { + if (current?.memo && memoIds.has(current.memo.id) && !summaries.has(current.memo.id)) { + summaries.set(current.memo.id, memoToSummary(current.memo)); + } + } + + return Array.from(summaries.values()); +}; + +export const removeMemoSummariesFromLists = (queryClient: QueryClient, memoIds: Set) => { + queryClient.setQueriesData({ queryKey: ["memos"] }, (current) => { + if (!current) { + return current; + } + + let changed = false; + const pages = current.pages.map((page) => { + const memos = page.memos.filter((memo) => !memoIds.has(memo.id)); + + if (memos.length === page.memos.length) { + return page; + } + + changed = true; + return { + ...page, + memos, + totalCount: Math.max(0, page.totalCount - (page.memos.length - memos.length)), + }; + }); + + return changed ? { ...current, pages } : current; + }); +}; + +export const clearTrashMemoLists = (queryClient: QueryClient) => { + for (const [queryKey, current] of queryClient.getQueriesData({ queryKey: ["memos", "trash"] })) { + if (!current) { + continue; + } + + queryClient.setQueryData(queryKey, { + ...current, + pages: current.pages.map((page) => ({ ...page, memos: [], totalCount: 0, nextCursor: null })), + }); + } +}; + +export const decrementNotebookMemoCounts = (queryClient: QueryClient, removedMemos: MemoSummary[]) => { + if (removedMemos.length === 0) { + return; + } + + const countsByNotebook = new Map(); + + for (const memo of removedMemos) { + if (memo.isDeleted) { + continue; + } + + countsByNotebook.set(memo.notebookId, (countsByNotebook.get(memo.notebookId) ?? 0) + 1); + } + + if (countsByNotebook.size === 0) { + return; + } + + queryClient.setQueryData(["notebooks"], (current) => + current + ? { + notebooks: current.notebooks.map((notebook) => { + const removedCount = countsByNotebook.get(notebook.id) ?? 0; + return removedCount > 0 ? { ...notebook, memoCount: Math.max(0, notebook.memoCount - removedCount) } : notebook; + }), + } + : current + ); +}; + +export const getAdjacentMemoIdAfterRemoval = (memos: MemoSummary[], removedMemoIds: Set, anchorMemoId: string) => { + const anchorIndex = memos.findIndex((memo) => memo.id === anchorMemoId); + + if (anchorIndex < 0) { + return null; + } + + for (let index = anchorIndex + 1; index < memos.length; index++) { + const memoId = memos[index]?.id; + if (memoId && !removedMemoIds.has(memoId)) { + return memoId; + } + } + + for (let index = anchorIndex - 1; index >= 0; index--) { + const memoId = memos[index]?.id; + if (memoId && !removedMemoIds.has(memoId)) { + return memoId; + } + } + + return null; +}; diff --git a/apps/web/src/lib/workspace-shell.ts b/apps/web/src/lib/workspace-shell.ts new file mode 100644 index 00000000..b80160f0 --- /dev/null +++ b/apps/web/src/lib/workspace-shell.ts @@ -0,0 +1,43 @@ +export const isDesktopViewport = () => window.matchMedia("(min-width: 1024px)").matches; +export const PULL_TO_REFRESH_TRIGGER_PX = 72; +export const PULL_TO_REFRESH_MAX_PX = 96; + +type ViewTransitionDocument = Document & { + startViewTransition?: (update: () => void) => unknown; +}; + +export const runWorkspaceViewTransition = (update: () => void) => { + const viewTransitionDocument = document as ViewTransitionDocument; + + if ( + !viewTransitionDocument.startViewTransition || + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ) { + update(); + return; + } + + viewTransitionDocument.startViewTransition(update); +}; + +export const isStandaloneApp = () => + window.matchMedia("(display-mode: standalone)").matches || + window.matchMedia("(display-mode: fullscreen)").matches || + Boolean((navigator as Navigator & { standalone?: boolean }).standalone); + +export const getVerticalScrollContainer = (target: EventTarget | null) => { + let element = target instanceof HTMLElement ? target : null; + + while (element && element !== document.body) { + const style = window.getComputedStyle(element); + const canScroll = /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight; + + if (canScroll) { + return element; + } + + element = element.parentElement; + } + + return null; +}; diff --git a/packages/shared/src/content.ts b/packages/shared/src/content.ts index ed8d0699..f3f9fdbc 100644 --- a/packages/shared/src/content.ts +++ b/packages/shared/src/content.ts @@ -1,8 +1,5 @@ -import Image from "@tiptap/extension-image"; -import { TaskItem, TaskList } from "@tiptap/extension-list"; -import { TableKit } from "@tiptap/extension-table"; -import { Markdown, MarkdownManager } from "@tiptap/markdown"; -import StarterKit from "@tiptap/starter-kit"; +import { MarkdownManager } from "@tiptap/markdown"; +import { createEdgeEverDocumentExtensions } from "./document-extensions"; import { MergeDivider, MERGE_DIVIDER_NODE_TYPE } from "./merge-divider"; import { PdfAttachment, PDF_ATTACHMENT_NODE_TYPE, upgradeStandalonePdfLinks } from "./pdf-attachment"; import { FileAttachment, FILE_ATTACHMENT_NODE_TYPE, upgradeStandaloneFileLinks } from "./file-attachment"; @@ -97,22 +94,10 @@ export const emptyDoc = (): TiptapDoc => ({ }); const markdownManager = new MarkdownManager({ - extensions: [ - StarterKit, - TaskList, - TaskItem.configure({ nested: true }), - TableKit, - Image, - ImageGallery, - PdfAttachment, - FileAttachment, - MergeDivider, - PluginEmbed, - ...createEdgeEverMarkdownMathematics(), - Markdown.configure({ - markedOptions: { gfm: true }, - }), - ], + extensions: createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMarkdownMathematics(), + markdown: true, + }), }); export const markdownToDoc = (markdown: string): TiptapDoc => { diff --git a/packages/shared/src/document-extensions.test.mjs b/packages/shared/src/document-extensions.test.mjs new file mode 100644 index 00000000..97969a26 --- /dev/null +++ b/packages/shared/src/document-extensions.test.mjs @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { + createEdgeEverDocumentExtensions, + docToMarkdown, + IMAGE_GALLERY_NODE_TYPE, + markdownToDoc, + MERGE_DIVIDER_MARKDOWN_MARKER, + MERGE_DIVIDER_NODE_TYPE, +} from "./index.ts"; +import { createEdgeEverMarkdownMathematics } from "./mathematics-markdown.ts"; + +const factorySource = readFileSync(resolve(import.meta.dir, "document-extensions.ts"), "utf8"); + +describe("shared document extensions", () => { + test("does not import the KaTeX mathematics entry", () => { + expect(factorySource).not.toContain('from "./mathematics"'); + expect(factorySource).not.toContain("createEdgeEverMathematics"); + }); + + test("registers the same document nodes the Markdown codec uses", () => { + const names = createEdgeEverDocumentExtensions({ + mathematics: createEdgeEverMarkdownMathematics(), + markdown: true, + }).map((extension) => extension.name); + + expect(names).toContain("starterKit"); + expect(names).toContain("taskList"); + expect(names).toContain("taskItem"); + expect(names).toContain("tableKit"); + expect(names).toContain("image"); + expect(names).toContain(IMAGE_GALLERY_NODE_TYPE); + expect(names).toContain("edgeeverPdfAttachment"); + expect(names).toContain("edgeeverFileAttachment"); + expect(names).toContain(MERGE_DIVIDER_NODE_TYPE); + expect(names).toContain("edgeeverPluginEmbed"); + expect(names).toContain("markdown"); + }); + + test("round-trips merge dividers and formulas through the codec factory", () => { + const markdown = `alpha\n\n${MERGE_DIVIDER_MARKDOWN_MARKER}\n\n---\n\nEuler: $e^{i\\pi}+1=0$.`; + const doc = markdownToDoc(markdown); + expect(doc.content.map((node) => node.type)).toEqual([ + "paragraph", + MERGE_DIVIDER_NODE_TYPE, + "paragraph", + ]); + expect(doc.content[2]?.content?.some((node) => node.type === "inlineMath")).toBe(true); + expect(docToMarkdown(doc)).toContain(MERGE_DIVIDER_MARKDOWN_MARKER); + }); +}); diff --git a/packages/shared/src/document-extensions.ts b/packages/shared/src/document-extensions.ts new file mode 100644 index 00000000..236cbc14 --- /dev/null +++ b/packages/shared/src/document-extensions.ts @@ -0,0 +1,52 @@ +import type { AnyExtension } from "@tiptap/core"; +import Image from "@tiptap/extension-image"; +import { TaskItem, TaskList } from "@tiptap/extension-list"; +import { TableKit } from "@tiptap/extension-table"; +import { Markdown } from "@tiptap/markdown"; +import StarterKit from "@tiptap/starter-kit"; +import { FileAttachment } from "./file-attachment"; +import { ImageGallery } from "./image-gallery"; +import { MergeDivider } from "./merge-divider"; +import { PdfAttachment } from "./pdf-attachment"; +import { PluginEmbed } from "./plugin-embed"; + +export type CreateEdgeEverDocumentExtensionsOptions = { + mathematics: AnyExtension[]; + starterKit?: Parameters[0]; + image?: AnyExtension | false; + gallery?: AnyExtension | false; + pdf?: AnyExtension | false; + file?: AnyExtension | false; + pluginEmbed?: AnyExtension | false; + table?: Parameters[0]; + markdown?: boolean; +}; + +const withOptional = (value: AnyExtension | false | undefined, fallback: AnyExtension) => { + if (value === false) return []; + return [value ?? fallback]; +}; + +/** + * Document-schema TipTap extensions shared by Markdown codecs and editors. + * Pass KaTeX math from `@edgeever/shared/mathematics` in browsers; Worker + * codecs must keep using `createEdgeEverMarkdownMathematics()`. + */ +export const createEdgeEverDocumentExtensions = ( + options: CreateEdgeEverDocumentExtensionsOptions, +): AnyExtension[] => [ + options.starterKit === undefined ? StarterKit : StarterKit.configure(options.starterKit), + TaskList, + TaskItem.configure({ nested: true }), + options.table === undefined ? TableKit : TableKit.configure(options.table), + ...withOptional(options.image, Image), + ...withOptional(options.gallery, ImageGallery), + ...withOptional(options.pdf, PdfAttachment), + ...withOptional(options.file, FileAttachment), + MergeDivider, + ...withOptional(options.pluginEmbed, PluginEmbed), + ...options.mathematics, + ...(options.markdown + ? [Markdown.configure({ markedOptions: { gfm: true } })] + : []), +]; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ca808a93..7adf023d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from "./content"; +export * from "./document-extensions"; export * from "./companion"; export * from "./public-demo"; export * from "./ai-assistant"; diff --git a/scripts/store-delivery.test.mjs b/scripts/store-delivery.test.mjs index 8c3158d4..1ab618f0 100644 --- a/scripts/store-delivery.test.mjs +++ b/scripts/store-delivery.test.mjs @@ -57,20 +57,22 @@ describe("store delivery command", () => { expect(workflow).not.toContain("bunx eas-cli"); expect(workflow.match(/uses: expo\/expo-github-action@v8/g)).toHaveLength( - 2, + 1, ); - expect(workflow.match(/eas-version: 21\.4\.0/g)).toHaveLength(2); - expect(workflow.match(/packager: npm/g)).toHaveLength(2); + expect(workflow.match(/eas-version: 21\.4\.0/g)).toHaveLength(1); + expect(workflow.match(/packager: npm/g)).toHaveLength(1); expect(workflow).toContain("for attempt in 1 2 3"); expect(workflow).toContain("Dependency install failed on attempt ${attempt}/3"); expect( workflow.match( /edgeever-bun-cache-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/g, ), - ).toHaveLength(3); - expect(workflow).toContain("inputs.ios_build_number == ''"); + ).toHaveLength(2); + expect(workflow).not.toContain("eas build"); + expect(workflow).not.toContain("--platform ios"); + expect(workflow).toContain("working-directory: apps/ios"); expect(workflow).toContain( - "APP_STORE_BUILD_NUMBER: ${{ inputs.ios_build_number || steps.build.outputs.build_number }}", + "APP_STORE_BUILD_NUMBER: ${{ inputs.ios_build_number }}", ); expect(workflow).toContain( "APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.EDGEEVER_APPLE_API_ISSUER }}", @@ -90,7 +92,7 @@ describe("store delivery command", () => { ); expect(workflow).toContain("retrying in 60 seconds (${attempt}/20)"); const fastfile = readFileSync( - new URL("../apps/mobile/fastlane/Fastfile", import.meta.url), + new URL("../apps/ios/fastlane/Fastfile", import.meta.url), "utf8", ); expect(fastfile).toContain("precheck_include_in_app_purchases: false"); diff --git a/tests/worker-bundle-boundary.test.ts b/tests/worker-bundle-boundary.test.ts index 19dc18e6..e7de6b74 100644 --- a/tests/worker-bundle-boundary.test.ts +++ b/tests/worker-bundle-boundary.test.ts @@ -10,11 +10,14 @@ describe("Worker bundle boundaries", () => { const packageJson = JSON.parse(readRepositoryFile("packages/shared/package.json")); const sharedIndex = readRepositoryFile("packages/shared/src/index.ts"); const contentSource = readRepositoryFile("packages/shared/src/content.ts"); + const documentExtensions = readRepositoryFile("packages/shared/src/document-extensions.ts"); expect(packageJson.exports["./mathematics"]).toBe("./src/mathematics.ts"); expect(sharedIndex).not.toContain('export * from "./mathematics"'); expect(contentSource).toContain('from "./mathematics-markdown"'); expect(contentSource).not.toContain('from "./mathematics"'); expect(contentSource).not.toContain("createEdgeEverMathematics"); + expect(documentExtensions).not.toContain('from "./mathematics"'); + expect(documentExtensions).not.toContain("createEdgeEverMathematics"); }); });