diff --git a/internal/api/api.go b/internal/api/api.go
index fc878cf..69e9472 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -85,6 +85,7 @@ func (s *Server) routes() []route {
{"POST /api/v1/move", authed, s.move},
{"POST /api/v1/folders", authed, s.mkdir},
{"POST /api/v1/assets", authed, s.uploadAsset},
+ {"POST /api/v1/assets/import", authed, s.importAsset},
{"GET /api/v1/raw/{path...}", authed, s.raw},
{"GET /api/v1/trash", authed, s.trashList},
{"POST /api/v1/trash/{id}/restore", authed, s.trashRestore},
diff --git a/internal/api/import.go b/internal/api/import.go
new file mode 100644
index 0000000..531037f
--- /dev/null
+++ b/internal/api/import.go
@@ -0,0 +1,229 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/netip"
+ "net/url"
+ "syscall"
+ "time"
+
+ "rawmark/internal/vault"
+)
+
+var (
+ errImageAddress = errors.New("图片地址必须是公网 HTTP 或 HTTPS 地址")
+ errImageType = errors.New("仅支持导入 PNG、JPEG、GIF 和 WebP 图片")
+)
+
+var blockedImageNetworks = []netip.Prefix{
+ netip.MustParsePrefix("0.0.0.0/8"),
+ netip.MustParsePrefix("100.64.0.0/10"),
+ netip.MustParsePrefix("192.0.0.0/24"),
+ netip.MustParsePrefix("192.0.2.0/24"),
+ netip.MustParsePrefix("198.18.0.0/15"),
+ netip.MustParsePrefix("198.51.100.0/24"),
+ netip.MustParsePrefix("203.0.113.0/24"),
+ netip.MustParsePrefix("240.0.0.0/4"),
+ netip.MustParsePrefix("2001::/23"),
+ netip.MustParsePrefix("2001:db8::/32"),
+ netip.MustParsePrefix("2002::/16"),
+ netip.MustParsePrefix("3fff::/20"),
+}
+
+func publicImageIP(ip netip.Addr) bool {
+ ip = ip.Unmap()
+ if !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
+ return false
+ }
+ // 不接受 IPv6 地址转换前缀,避免其中嵌入的 IPv4 地址访问内网。
+ if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) {
+ return false
+ }
+ for _, prefix := range blockedImageNetworks {
+ if prefix.Contains(ip) {
+ return false
+ }
+ }
+ return true
+}
+
+func checkImageURL(u *url.URL) error {
+ if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil {
+ return errImageAddress
+ }
+ return nil
+}
+
+var remoteImageClient = &http.Client{
+ Timeout: 10 * time.Second,
+ Transport: &http.Transport{
+ // 不使用环境代理,确保每个实际连接都经过目标地址校验。
+ DialContext: (&net.Dialer{
+ Timeout: 5 * time.Second,
+ ControlContext: func(_ context.Context, _, address string, _ syscall.RawConn) error {
+ host, _, err := net.SplitHostPort(address)
+ if err != nil {
+ return err
+ }
+ ip, err := netip.ParseAddr(host)
+ if err != nil || !publicImageIP(ip) {
+ return errImageAddress
+ }
+ local, err := net.InterfaceAddrs()
+ if err != nil {
+ return err
+ }
+ for _, addr := range local {
+ prefix, err := netip.ParsePrefix(addr.String())
+ if err != nil {
+ return err
+ }
+ if ip.Unmap() == prefix.Addr().Unmap() {
+ return errImageAddress
+ }
+ }
+ return nil
+ },
+ }).DialContext,
+ TLSHandshakeTimeout: 5 * time.Second,
+ IdleConnTimeout: 30 * time.Second,
+ ForceAttemptHTTP2: true,
+ },
+ CheckRedirect: func(r *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return errors.New("图片来源重定向次数过多")
+ }
+ r.Header.Del("Referer")
+ return checkImageURL(r.URL)
+ },
+}
+
+// readImageDownload 按实际文件签名确定类型,不信任来源的扩展名或 Content-Type。
+func readImageDownload(body io.Reader) ([]byte, string, error) {
+ data, err := io.ReadAll(io.LimitReader(body, maxAssetBytes+1))
+ if err != nil {
+ return nil, "", err
+ }
+ if int64(len(data)) > maxAssetBytes {
+ return nil, "", vault.ErrTooLarge
+ }
+ var ext string
+ switch http.DetectContentType(data) {
+ case "image/png":
+ ext = ".png"
+ case "image/jpeg":
+ ext = ".jpg"
+ case "image/gif":
+ ext = ".gif"
+ case "image/webp":
+ ext = ".webp"
+ default:
+ return nil, "", errImageType
+ }
+ return data, ext, nil
+}
+
+func (s *Server) importAsset(w http.ResponseWriter, r *http.Request) {
+ var body struct{ URL string }
+ if !readJSON(w, r, &body) {
+ return
+ }
+ u, err := url.Parse(body.URL)
+ if err != nil || checkImageURL(u) != nil {
+ writeError(w, 400, errImageAddress.Error())
+ return
+ }
+ note := r.URL.Query().Get("note")
+ if !vault.IsNote(note) {
+ writeError(w, 400, "所属笔记路径无效")
+ return
+ }
+ if _, err := s.vault.Stat(note); err != nil {
+ vaultError(w, r, err)
+ return
+ }
+ for attempt := 0; attempt < 2; attempt++ {
+ if attempt > 0 {
+ timer := time.NewTimer(time.Second)
+ select {
+ case <-r.Context().Done():
+ timer.Stop()
+ return
+ case <-timer.C:
+ }
+ }
+ req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, u.String(), nil)
+ if err != nil {
+ writeError(w, 400, errImageAddress.Error())
+ return
+ }
+ res, err := remoteImageClient.Do(req)
+ if err != nil {
+ var timeout net.Error
+ if attempt == 0 && errors.As(err, &timeout) && timeout.Timeout() && r.Context().Err() == nil {
+ continue
+ }
+ if errors.Is(err, errImageAddress) {
+ writeError(w, 400, errImageAddress.Error())
+ return
+ }
+ // url.Error 含完整来源 URL,其中可能有临时签名,日志只保留底层原因。
+ var urlError *url.Error
+ if errors.As(err, &urlError) {
+ err = urlError.Err
+ }
+ slog.Error("下载图片失败", "err", err)
+ writeError(w, 502, "下载图片失败")
+ return
+ }
+ if res.StatusCode < 200 || res.StatusCode >= 300 {
+ res.Body.Close()
+ if attempt == 0 && (res.StatusCode == 429 || res.StatusCode >= 500 && res.StatusCode < 600) {
+ continue
+ }
+ writeError(w, 502, fmt.Sprintf("图片来源返回 HTTP %d", res.StatusCode))
+ return
+ }
+ data, ext, err := readImageDownload(res.Body)
+ res.Body.Close()
+ if err != nil {
+ var timeout net.Error
+ if attempt == 0 && errors.As(err, &timeout) && timeout.Timeout() && r.Context().Err() == nil {
+ continue
+ }
+ if errors.Is(err, vault.ErrTooLarge) {
+ vaultError(w, r, err)
+ } else if errors.Is(err, errImageType) {
+ writeError(w, 422, err.Error())
+ } else {
+ slog.Error("读取下载图片失败", "err", err)
+ writeError(w, 502, "读取下载图片失败")
+ }
+ return
+ }
+ // 下载期间笔记可能被外部程序移动或删除;不能在旧路径下创建孤立资源。
+ info, err := s.vault.Stat(note)
+ if err != nil {
+ vaultError(w, r, err)
+ return
+ }
+ if info.IsDir() {
+ writeError(w, 400, "所属路径必须是笔记文件")
+ return
+ }
+ link, err := s.vault.SaveAsset(note, "image"+ext, bytes.NewReader(data), maxAssetBytes)
+ if err != nil {
+ vaultError(w, r, err)
+ return
+ }
+ writeJSON(w, 200, map[string]string{"link": link})
+ return
+ }
+}
diff --git a/internal/api/import_test.go b/internal/api/import_test.go
new file mode 100644
index 0000000..606ac56
--- /dev/null
+++ b/internal/api/import_test.go
@@ -0,0 +1,142 @@
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/netip"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "rawmark/internal/vault"
+)
+
+func TestImportImageAddressBoundary(t *testing.T) {
+ for _, address := range []string{"127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.0.1", "169.254.169.254", "100.100.100.200", "0.1.2.3", "198.18.0.1", "224.0.0.1", "240.0.0.1", "::", "::1", "::ffff:127.0.0.1", "fc00::1", "fe80::1", "64:ff9b::7f00:1", "2002:7f00:1::", "2001::1", "2001:db8::1", "3fff::1"} {
+ if publicImageIP(netip.MustParseAddr(address)) {
+ t.Errorf("不应接受内网或特殊用途地址 %s", address)
+ }
+ }
+ for _, address := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} {
+ if !publicImageIP(netip.MustParseAddr(address)) {
+ t.Errorf("应接受公网地址 %s", address)
+ }
+ }
+ f := newFixture(t)
+ f.write("n.md", "正文")
+ for _, source := range []string{"file:///etc/passwd", "ftp://example.com/a.png", "https://user:password@example.com/a.png", "http://127.0.0.1/a.png", "http://[::1]/a.png", "http://localhost/a.png"} {
+ body, err := json.Marshal(map[string]string{"url": source})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if code := f.status(f.admin, "POST", "/api/v1/assets/import?note=n.md", string(body), nil); code != 400 {
+ t.Fatalf("应拒绝非公网图片来源,得到 %d", code)
+ }
+ }
+ if code := f.status(newClient(), "POST", "/api/v1/assets/import?note=n.md", `{"url":"https://example.com/a.png"}`, nil); code != 401 {
+ t.Fatalf("未登录不允许下载: %d", code)
+ }
+}
+
+func TestImportImageDownloadAndRedirects(t *testing.T) {
+ png, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aT6sAAAAASUVORK5CYII=")
+ if err != nil {
+ t.Fatal(err)
+ }
+ attempts := map[string]int{}
+ var ownerRoot string
+ source := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ attempts[r.URL.Path]++
+ if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" || r.Header.Get("Referer") != "" {
+ t.Error("下载请求泄露了凭据或来源")
+ }
+ switch r.URL.Path {
+ case "/private":
+ http.Redirect(w, r, "http://127.0.0.1/private.png", http.StatusFound)
+ case "/scheme":
+ http.Redirect(w, r, "file:///etc/passwd", http.StatusFound)
+ case "/redirect":
+ http.Redirect(w, r, "/image", http.StatusFound)
+ case "/loop":
+ http.Redirect(w, r, "/loop", http.StatusFound)
+ case "/retry":
+ if attempts[r.URL.Path] == 1 {
+ w.WriteHeader(503)
+ return
+ }
+ w.Write(png)
+ case "/failed":
+ w.WriteHeader(429)
+ case "/missing":
+ w.WriteHeader(404)
+ case "/renamed":
+ if err := os.Rename(filepath.Join(ownerRoot, "moving.md"), filepath.Join(ownerRoot, "moved.md")); err != nil {
+ t.Error(err)
+ }
+ w.Write(png)
+ case "/html":
+ w.Header().Set("Content-Type", "image/png")
+ io.WriteString(w, "")
+ default:
+ w.Header().Set("Content-Type", "text/plain")
+ w.Write(png)
+ }
+ }))
+ defer source.Close()
+ original := remoteImageClient
+ transport := original.Transport.(*http.Transport).Clone()
+ dial := transport.DialContext
+ transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
+ // 只把固定的测试来源映射到本地真实 HTTP 服务,其他目标仍走生产地址校验。
+ if address == "images.example:80" {
+ return (&net.Dialer{}).DialContext(ctx, network, source.Listener.Addr().String())
+ }
+ return dial(ctx, network, address)
+ }
+ client := *original
+ client.Transport = transport
+ remoteImageClient = &client
+ t.Cleanup(func() { transport.CloseIdleConnections(); remoteImageClient = original })
+ f := newFixture(t)
+ ownerRoot = f.root
+ f.write("n.md", "正文")
+ for _, route := range []string{"/image", "/redirect", "/retry"} {
+ body := `{"url":"http://images.example` + route + `"}`
+ result := decode[map[string]string](t, f.do(f.admin, "POST", "/api/v1/assets/import?note=n.md", body, nil))
+ data, err := os.ReadFile(filepath.Join(f.root, filepath.FromSlash(result["link"])))
+ if err != nil || !bytes.Equal(data, png) || !strings.HasSuffix(result["link"], ".png") {
+ t.Fatalf("导入内容、类型或路径不符: %v", err)
+ }
+ }
+ for route, expected := range map[string]int{"/private": 400, "/scheme": 400, "/loop": 502, "/html": 422, "/missing": 502, "/failed": 502} {
+ body := `{"url":"http://images.example` + route + `"}`
+ if code := f.status(f.admin, "POST", "/api/v1/assets/import?note=n.md", body, nil); code != expected {
+ t.Errorf("%s 应返回 %d,得到 %d", route, expected, code)
+ }
+ }
+ if attempts["/retry"] != 2 || attempts["/failed"] != 2 || attempts["/missing"] != 1 || attempts["/loop"] != 5 {
+ t.Fatalf("重试或重定向没有遵守上限: %v", attempts)
+ }
+ f.write("moving.md", "正文")
+ if code := f.status(f.admin, "POST", "/api/v1/assets/import?note=moving.md", `{"url":"http://images.example/renamed"}`, nil); code != 404 {
+ t.Fatalf("下载期间移动的笔记不应写入旧路径: %d", code)
+ }
+ if _, err := os.Stat(filepath.Join(f.root, "moving.assets")); !os.IsNotExist(err) {
+ t.Fatalf("不应在旧位置创建资源: %v", err)
+ }
+}
+
+func TestReadImageDownloadSizeLimit(t *testing.T) {
+ _, _, err := readImageDownload(bytes.NewReader(make([]byte, maxAssetBytes+1)))
+ if !errors.Is(err, vault.ErrTooLarge) {
+ t.Fatalf("超限图片应被拒绝: %v", err)
+ }
+}
diff --git a/internal/api/openapi.json b/internal/api/openapi.json
index aeaa556..08719cb 100644
--- a/internal/api/openapi.json
+++ b/internal/api/openapi.json
@@ -790,6 +790,111 @@
}
}
},
+ "/assets/import": {
+ "post": {
+ "summary": "下载公网图片到笔记的 .assets 目录",
+ "tags": [
+ "图片"
+ ],
+ "description": "仅接受公网 HTTP/HTTPS 地址,实际连接及重定向均校验;不携带登录凭据或使用环境代理。重定向链最多 5 个请求、2 次下载尝试,单次超时 10 秒;仅超时、429 和 5xx 重试。支持 PNG/JPEG/GIF/WebP,按文件签名识别,最大 50 MB。",
+ "parameters": [
+ {
+ "name": "note",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "required": [
+ "url"
+ ],
+ "properties": {
+ "url": {
+ "type": "string",
+ "format": "uri"
+ }
+ }
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "相对笔记所在目录的图片链接",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "link": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "来源地址或笔记路径无效,或目标不是公网",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "所属笔记不存在",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "413": {
+ "description": "图片超过 50 MB",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "422": {
+ "description": "不支持的图片类型",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ },
+ "502": {
+ "description": "来源请求失败",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/Error"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
"/raw/{path}": {
"parameters": [
{
diff --git a/web/package-lock.json b/web/package-lock.json
index ae80e87..3778a5b 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -18,9 +18,11 @@
"katex": "^0.18.11",
"mermaid": "^12.2.0",
"shiki": "^4.5.0",
+ "turndown": "^7.2.4",
"vue": "^3.5.44"
},
"devDependencies": {
+ "@types/turndown": "^5.0.6",
"@vitejs/plugin-vue": "^6.0.10",
"commonmark-spec": "^0.31.2",
"happy-dom": "^21.0.3",
@@ -398,6 +400,12 @@
"node": ">=22.12.0"
}
},
+ "node_modules/@mixmark-io/domino": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
+ "integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
+ "license": "BSD-2-Clause"
+ },
"node_modules/@oxc-project/types": {
"version": "0.154.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz",
@@ -1107,6 +1115,13 @@
"license": "MIT",
"optional": true
},
+ "node_modules/@types/turndown": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
+ "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@@ -3099,6 +3114,19 @@
"node": ">=6.10"
}
},
+ "node_modules/turndown": {
+ "version": "7.2.4",
+ "resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
+ "integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@mixmark-io/domino": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=18",
+ "npm": ">=9"
+ }
+ },
"node_modules/typescript": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
diff --git a/web/package.json b/web/package.json
index 16b1712..82fcce9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -21,9 +21,11 @@
"katex": "^0.18.11",
"mermaid": "^12.2.0",
"shiki": "^4.5.0",
+ "turndown": "^7.2.4",
"vue": "^3.5.44"
},
"devDependencies": {
+ "@types/turndown": "^5.0.6",
"@vitejs/plugin-vue": "^6.0.10",
"commonmark-spec": "^0.31.2",
"happy-dom": "^21.0.3",
diff --git a/web/src/app/Workspace.vue b/web/src/app/Workspace.vue
index 27c96f6..8afcfae 100644
--- a/web/src/app/Workspace.vue
+++ b/web/src/app/Workspace.vue
@@ -15,7 +15,7 @@ import { noteTitle } from './paths'
import { SIDEBAR_WIDTH, settings, updateSettings } from './settings'
import Sidebar from './Sidebar.vue'
import TabBar from './TabBar.vue'
-import { activeTab, flushAll, startWorkspace, stopWorkspace, toast, ws } from './workspace'
+import { activeTab, flushAll, readyToReload, startWorkspace, stopWorkspace, toast, ws } from './workspace'
const emit = defineEmits<{ logout: [] }>()
const title = computed(() => (ws.active ? noteTitle(ws.active) : 'RawMark'))
@@ -43,6 +43,10 @@ function startResize(e: PointerEvent) {
async function logout() {
await flushAll()
+ if (!readyToReload()) {
+ toast('仍有未保存内容或正在处理粘贴、上传与文件操作,请完成后再退出')
+ return
+ }
try {
await api.logout()
} catch {
diff --git a/web/src/app/api.ts b/web/src/app/api.ts
index c4250d9..4c3d72d 100644
--- a/web/src/app/api.ts
+++ b/web/src/app/api.ts
@@ -159,6 +159,9 @@ export const api = {
return (await json<{ link: string }>(request('POST', '/api/v1/assets?note=' + encodeURIComponent(note), { body: form }))).link
},
+ importImage: async (note: string, url: string): Promise =>
+ (await json<{ link: string }>(request('POST', '/api/v1/assets/import?note=' + encodeURIComponent(note), { json: { url } }))).link,
+
shares: async (path?: string) =>
(await json<{ shares: Share[] }>(request('GET', '/api/v1/shares' + (path ? '?path=' + encodeURIComponent(path) : '')))).shares,
// expiresIn 为有效时长(秒),0 为永久
diff --git a/web/src/app/workspace.test.ts b/web/src/app/workspace.test.ts
index 6ca742a..1de3954 100644
--- a/web/src/app/workspace.test.ts
+++ b/web/src/app/workspace.test.ts
@@ -6,7 +6,7 @@ import { api, ApiError } from './api'
import { drafts, type Draft } from './drafts'
import { connectEvents } from './events'
import { pluginEvents } from '../plugins/registry'
-import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, ws } from './workspace'
+import { attachView, closeTab, editorStateOf, flush, keepLocalVersion, openNote, startWorkspace, stopWorkspace, storeViewState, ws } from './workspace'
vi.mock('./events', () => ({ connectEvents: vi.fn(() => () => {}) }))
@@ -136,6 +136,32 @@ describe('保存与关闭', () => {
await closeTab('race.md')
})
+ it('网页图片导入期间切换标签,转换内容仍保存到原笔记', async () => {
+ const v = await edit('网页.md')
+ vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
+ const contents = new Map()
+ vi.spyOn(api, 'writeFile').mockImplementation(async (path, content) => { contents.set(path, content); return 'r2' })
+ let finish!: (link: string) => void
+ vi.spyOn(api, 'importImage').mockImplementation(() => new Promise((resolve) => { finish = resolve }))
+ const data = new DataTransfer()
+ data.setData('text/plain', '网页标题')
+ data.setData('text/html', '网页标题
')
+ v.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
+ await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
+ expect(await closeTab('网页.md')).toBe(false)
+ await openNote('other.md')
+ storeViewState('网页.md', v.state)
+ v.setState(editorStateOf('other.md')!)
+ finish('网页.assets/hash.png')
+ await vi.waitFor(() => expect(editorStateOf('网页.md')!.doc.toString()).toContain('网页.assets/hash.png'))
+ await flush('网页.md')
+ expect(contents.get('网页.md')).toContain('# 网页标题')
+ expect(contents.get('网页.md')).toContain('网页.assets/hash.png')
+ expect(v.state.doc.toString()).toBe('原文')
+ await closeTab('网页.md')
+ await closeTab('other.md')
+ })
+
it('切换标签后上传结果仍写回原笔记,上传期间不能关闭', async () => {
const v = await edit('image.md')
vi.spyOn(drafts, 'put').mockImplementation(async (draft) => draft.path)
diff --git a/web/src/app/workspace.ts b/web/src/app/workspace.ts
index df9bce2..b00e00d 100644
--- a/web/src/app/workspace.ts
+++ b/web/src/app/workspace.ts
@@ -188,21 +188,23 @@ function buildState(path: string, doc: string): EditorState {
}),
pasteImages({
upload: (file) => api.upload(ref.path, file),
- replace: (text, replacement) => {
+ importImage: (url) => api.importImage(ref.path, url),
+ update: (change) => {
const shown = view?.state.facet(notePath) === ref
const state = shown ? view!.state : editorStates.get(ref.path)
- if (!state) throw new Error('上传所属笔记的编辑状态不存在')
- const at = state.doc.toString().indexOf(text)
- if (at < 0) return // 用户已删除占位
- const spec = { changes: { from: at, to: at + text.length, insert: replacement } }
+ if (!state) throw new Error('粘贴所属笔记的编辑状态不存在')
+ const spec = change(state)
+ if (!spec) return
if (shown) view!.dispatch(spec)
else {
- editorStates.set(ref.path, state.update(spec).state)
- scheduleSave(ref.path)
+ const transaction = state.update(spec)
+ editorStates.set(ref.path, transaction.state)
+ if (transaction.docChanged) scheduleSave(ref.path)
}
},
- onError: (err) => toast('图片上传失败:' + errorText(err)),
+ onError: (err) => toast('粘贴或图片导入失败:' + (err instanceof Error ? err.message : errorText(err))),
onPending: (d) => {
+ if (d === 1 && slot(ref.path).uploads === 0) toast('正在处理粘贴或图片导入…')
slot(ref.path).uploads += d
},
}),
@@ -911,6 +913,9 @@ export async function startWorkspace() {
if (document.visibilityState === 'hidden') void flushAll(true)
})
window.addEventListener('pagehide', () => void flushAll(true))
+ window.addEventListener('beforeunload', (event) => {
+ if (!readyToReload()) event.preventDefault()
+ })
window.addEventListener('keydown', handleShortcut)
// 软键盘弹出、编辑区变矮后,让光标保持可见
trackVisualViewport(() => {
diff --git a/web/src/editor/html-paste.ts b/web/src/editor/html-paste.ts
new file mode 100644
index 0000000..d8e05a9
--- /dev/null
+++ b/web/src/editor/html-paste.ts
@@ -0,0 +1,74 @@
+import TurndownService from 'turndown'
+
+export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise, onError: (error: unknown) => void): Promise {
+ // template 中的外部资源和脚本保持惰性,不让剪贴板 HTML 进入活动页面。
+ const template = document.createElement('template')
+ template.innerHTML = html
+ const fragment = template.content
+ const base = fragment.querySelector('base[href]')?.getAttribute('href')
+ fragment.querySelectorAll('script,style,iframe,object,embed,template,meta,link,base').forEach((node) => node.remove())
+ if (base) {
+ for (const node of fragment.querySelectorAll('a[href],img[src]')) {
+ const attribute = node.tagName === 'A' ? 'href' : 'src'
+ try { node.setAttribute(attribute, new URL(node.getAttribute(attribute)!, base).href) }
+ catch (error) { onError(new Error('来源基址无效,已保留原链接', { cause: error })) }
+ }
+ }
+
+ const images = new Map()
+ for (const image of fragment.querySelectorAll('img')) {
+ let src = image.getAttribute('src') ?? ''
+ if (src.startsWith('//')) { src = 'https:' + src; image.setAttribute('src', src) }
+ if (!/^https?:\/\//i.test(src)) continue
+ const group = images.get(src) ?? []
+ group.push(image)
+ images.set(src, group)
+ }
+ const sources = [...images]
+ // 网页常有多张图片;限制并行数量,避免一次粘贴占满浏览器连接和服务端下载内存。
+ for (let offset = 0; offset < sources.length; offset += 3) {
+ await Promise.all(sources.slice(offset, offset + 3).map(async ([src, nodes]) => {
+ try {
+ const link = await importImage(src)
+ for (const node of nodes) node.setAttribute('src', link)
+ } catch (error) { onError(error) }
+ }))
+ }
+
+ const service = new TurndownService({ headingStyle: 'atx', bulletListMarker: '-', codeBlockStyle: 'fenced' })
+ service.addRule('strikethrough', { filter: (node) => ['DEL', 'S', 'STRIKE'].includes(node.tagName), replacement: (content) => '~~' + content + '~~' })
+ service.addRule('table', {
+ filter: 'table',
+ replacement: (_content, node) => {
+ const rows = [...node.querySelectorAll('tr')].filter((row) => row.closest('table') === node).map((row) =>
+ [...row.cells].map((cell) =>
+ service.turndown(cell).replace(/\|/g, '\\|').replace(/\n+/g, ' '),
+ ),
+ )
+ if (!rows.length) return ''
+ const columns = Math.max(...rows.map((row) => row.length))
+ if (!columns) return ''
+ const line = (cells: string[]) => '| ' + Array.from({ length: columns }, (_, index) => cells[index] ?? '').join(' | ') + ' |'
+ return '\n\n' + [line(rows[0]), line(Array(columns).fill('---')), ...rows.slice(1).map(line)].join('\n') + '\n\n'
+ },
+ })
+ // 链接目标用尖括号包裹,避免 URL 中的括号截断 Markdown;不保留可执行协议。
+ service.addRule('link', {
+ filter: 'a',
+ replacement: (content, node) => {
+ const href = node.getAttribute('href') ?? ''
+ return href && !/^[\s\u0000-\u0020]*(?:javascript|vbscript|data|file):/i.test(href.replace(/[\u0000-\u0020]/g, ''))
+ ? '[' + content + '](<' + href.replace(//g, '%3E') + '>)' : content
+ },
+ })
+ service.addRule('image', {
+ filter: 'img',
+ replacement: (_content, node) => {
+ const src = node.getAttribute('src') ?? ''
+ const alt = (node.getAttribute('alt') ?? '').replace(/[\[\]\\]/g, '\\$&').replace(/\n/g, ' ')
+ return src && !/^(?:javascript|vbscript|file):/i.test(src.replace(/[\u0000-\u0020]/g, ''))
+ ? ' + '>)' : alt
+ },
+ })
+ return service.turndown(fragment)
+}
diff --git a/web/src/editor/paste.test.ts b/web/src/editor/paste.test.ts
new file mode 100644
index 0000000..d697a89
--- /dev/null
+++ b/web/src/editor/paste.test.ts
@@ -0,0 +1,114 @@
+// @vitest-environment happy-dom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { history, undo } from '@codemirror/commands'
+import { EditorState } from '@codemirror/state'
+import { EditorView } from '@codemirror/view'
+import { htmlToMarkdown } from './html-paste'
+import { pasteImages } from './paste'
+
+let view: EditorView | undefined
+afterEach(() => { view?.destroy(); view = undefined; vi.restoreAllMocks() })
+
+function paste(html: string, text: string) {
+ const data = new DataTransfer()
+ data.setData('text/html', html)
+ data.setData('text/plain', text)
+ view!.contentDOM.dispatchEvent(new ClipboardEvent('paste', { clipboardData: data, bubbles: true, cancelable: true }))
+}
+
+function editor(importImage: (url: string) => Promise = async () => { throw new Error('此用例不下载图片') }) {
+ let pending = 0
+ const errors: unknown[] = []
+ view = new EditorView({ parent: document.body, state: EditorState.create({ doc: '', extensions: [history(), pasteImages({
+ upload: async () => { throw new Error('此用例不上传文件') },
+ importImage,
+ update: (change) => { const spec = change(view!.state); if (spec) view!.dispatch(spec) },
+ onError: (error) => errors.push(error),
+ onPending: (delta) => { pending += delta },
+ })] }) })
+ return { pending: () => pending, errors }
+}
+
+describe('网页粘贴', () => {
+ it('保留标题、列表、链接、代码块、删除线和表格,并移除活动内容', async () => {
+ const result = await htmlToMarkdown('标题
链接const x = 1\n
危险链接', async () => '', () => {})
+ expect(result).toContain('## 标题')
+ expect(result).toMatch(/-\s+一/)
+ expect(result).toContain('~~二~~')
+ expect(result).toContain('[链接]()')
+ expect(result).toContain('```js\nconst x = 1\n```')
+ expect(result).toContain('| 名称 | 值 |\n| --- | --- |\n| **甲** | a\\|b |')
+ expect(result).not.toContain('alert(1)')
+ expect(result).not.toContain('body{}')
+ expect(result).toContain('危险链接')
+ })
+
+ it('相同来源只下载一次,失败保留原图链接并报告原始原因', async () => {
+ const failure = new Error('来源拒绝下载')
+ const errors: unknown[] = []
+ const urls: string[] = []
+ const result = await htmlToMarkdown('

', async (url) => {
+ urls.push(url)
+ if (url.endsWith('bad.png')) throw failure
+ return '笔记.assets/hash.png'
+ }, (error) => errors.push(error))
+ expect(urls.sort()).toEqual(['https://example.com/a.png', 'https://example.com/docs/bad.png'])
+ expect(result.match(/笔记.assets\/hash.png/g)).toHaveLength(2)
+ expect(result).toContain('https://example.com/docs/bad.png')
+ expect(errors).toEqual([failure])
+ })
+
+ it('转换期间的新输入保持原样,撤销一次移除转换后的整次粘贴', async () => {
+ const state = editor()
+ paste('标题
', '标题')
+ view!.dispatch({ changes: { from: 0, insert: '之后输入' }, userEvent: 'input.type' })
+ await vi.waitFor(() => expect(state.pending()).toBe(0))
+ expect(view!.state.doc.toString()).toBe('# 标题之后输入')
+ undo(view!)
+ expect(view!.state.doc.toString()).toBe('之后输入')
+ undo(view!)
+ expect(view!.state.doc.toString()).toBe('')
+ expect(state.errors).toEqual([])
+ })
+
+ it('用户已修改粘贴区域时不覆盖新输入,Shift 粘贴保持纯文本', async () => {
+ const state = editor()
+ view!.dispatch({ changes: { from: 0, insert: '原选区' }, selection: { anchor: 0, head: 3 } })
+ paste('标题
', '标题')
+ view!.dispatch({ changes: { from: 1, to: 3, insert: '新输入' } })
+ await vi.waitFor(() => expect(state.pending()).toBe(0))
+ expect(view!.state.doc.toString()).toBe('原新输入')
+ view!.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key: 'V', shiftKey: true, ctrlKey: true, bubbles: true }))
+ paste('纯文本
', '纯文本')
+ expect(view!.state.doc.toString()).not.toContain('#')
+ expect(view!.state.doc.toString()).toContain('纯文本')
+ expect(state.pending()).toBe(0)
+ })
+
+ it('同一位置连续发起的异步粘贴保持发起顺序', async () => {
+ const state = editor()
+ paste('第一段', '第一段')
+ paste('第二段', '第二段')
+ await vi.waitFor(() => expect(state.pending()).toBe(0))
+ expect(view!.state.doc.toString()).toBe('**第一段****第二段**')
+ })
+
+ it('后发粘贴的图片先完成时仍保持顺序,撤销会取消尚未插入的粘贴', async () => {
+ const downloads = new Map void>()
+ const state = editor((url) => new Promise((resolve) => downloads.set(url, resolve)))
+ paste('第一段
', '第一段')
+ paste('第二段
', '第二段')
+ await vi.waitFor(() => expect(downloads.size).toBe(2))
+ downloads.get('https://example.com/2.png')!('n.assets/2.png')
+ await vi.waitFor(() => expect(view!.state.doc.toString()).toContain('第二段'))
+ downloads.get('https://example.com/1.png')!('n.assets/1.png')
+ await vi.waitFor(() => expect(state.pending()).toBe(0))
+ expect(view!.state.doc.toString().indexOf('第一段')).toBeLessThan(view!.state.doc.toString().indexOf('第二段'))
+ paste('取消的粘贴
', '取消的粘贴')
+ await vi.waitFor(() => expect(downloads.size).toBe(3))
+ undo(view!)
+ downloads.get('https://example.com/3.png')!('n.assets/3.png')
+ await vi.waitFor(() => expect(state.pending()).toBe(0))
+ expect(view!.state.doc.toString()).not.toContain('取消的粘贴')
+ })
+})
diff --git a/web/src/editor/paste.ts b/web/src/editor/paste.ts
index 6f22ec3..9716457 100644
--- a/web/src/editor/paste.ts
+++ b/web/src/editor/paste.ts
@@ -1,11 +1,13 @@
-import { Facet, type Extension } from '@codemirror/state'
+import { isolateHistory } from '@codemirror/commands'
+import { Facet, StateEffect, StateField, type EditorState, type Extension, type TransactionSpec } from '@codemirror/state'
import { EditorView } from '@codemirror/view'
export interface PasteImagesOptions {
// 上传文件,返回写入 Markdown 的链接
upload: (file: File) => Promise
- // 按笔记身份修改占位,上传结束时该笔记可能已切到后台
- replace: (text: string, replacement: string) => void
+ importImage: (url: string) => Promise
+ // 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
+ update: (change: (state: EditorState) => TransactionSpec | undefined) => void
onError: (err: unknown) => void
// 上传开始/结束,供保存队列在上传期间暂缓保存(避免把临时的 blob: 地址写入文件)
onPending: (delta: 1 | -1) => void
@@ -13,6 +15,63 @@ export interface PasteImagesOptions {
const uploadOptions = Facet.define({ combine: (v) => v[0] })
+const pasteRange = StateEffect.define<{ id: object; from: number; to: number } | { id: object }>()
+const pendingPastes = StateField.define