Convert pasted web content to Markdown and import remote images
This commit is contained in:
1 parent
84e89c8fe4
commit
8c13dfeaec
13 files changed
+820
-15
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:[email protected]/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, "<script>not an image</script>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
Generated
+28
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string> =>
|
||||
(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 为永久
|
||||
|
||||
@@ -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<string, string>()
|
||||
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<string>((resolve) => { finish = resolve }))
|
||||
const data = new DataTransfer()
|
||||
data.setData('text/plain', '网页标题')
|
||||
data.setData('text/html', '<h1>网页标题</h1><img src="https://example.com/a.png">')
|
||||
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)
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import TurndownService from 'turndown'
|
||||
|
||||
export async function htmlToMarkdown(html: string, importImage: (url: string) => Promise<string>, onError: (error: unknown) => void): Promise<string> {
|
||||
// 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<string, HTMLImageElement[]>()
|
||||
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, '%3C').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, ''))
|
||||
? '.replace(/>/g, '%3E') + '>)' : alt
|
||||
},
|
||||
})
|
||||
return service.turndown(fragment)
|
||||
}
|
||||
@@ -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<string> = 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('<h2>标题</h2><ul><li>一</li><li><del>二</del></li></ul><a href="https://example.com/a(b)">链接</a><pre><code class="language-js">const x = 1\n</code></pre><table><tr><th>名称</th><th>值</th></tr><tr><td><strong>甲</strong></td><td>a|b</td></tr></table><script>alert(1)</script><style>body{}</style><a href="java	script:alert(1)">危险链接</a>', async () => '', () => {})
|
||||
expect(result).toContain('## 标题')
|
||||
expect(result).toMatch(/-\s+一/)
|
||||
expect(result).toContain('~~二~~')
|
||||
expect(result).toContain('[链接](<https://example.com/a(b)>)')
|
||||
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('<base href="https://example.com/docs/"><img alt="图" src="../a.png"><img src="//example.com/a.png"><img src="bad.png">', 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('<h1>标题</h1>', '标题')
|
||||
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('<h1>标题</h1>', '标题')
|
||||
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('<h1>纯文本</h1>', '纯文本')
|
||||
expect(view!.state.doc.toString()).not.toContain('#')
|
||||
expect(view!.state.doc.toString()).toContain('纯文本')
|
||||
expect(state.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('同一位置连续发起的异步粘贴保持发起顺序', async () => {
|
||||
const state = editor()
|
||||
paste('<b>第一段</b>', '第一段')
|
||||
paste('<b>第二段</b>', '第二段')
|
||||
await vi.waitFor(() => expect(state.pending()).toBe(0))
|
||||
expect(view!.state.doc.toString()).toBe('**第一段****第二段**')
|
||||
})
|
||||
|
||||
it('后发粘贴的图片先完成时仍保持顺序,撤销会取消尚未插入的粘贴', async () => {
|
||||
const downloads = new Map<string, (link: string) => void>()
|
||||
const state = editor((url) => new Promise((resolve) => downloads.set(url, resolve)))
|
||||
paste('<b>第一段</b><img src="https://example.com/1.png">', '第一段')
|
||||
paste('<b>第二段</b><img src="https://example.com/2.png">', '第二段')
|
||||
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('<b>取消的粘贴</b><img src="https://example.com/3.png">', '取消的粘贴')
|
||||
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('取消的粘贴')
|
||||
})
|
||||
})
|
||||
+77
-5
@@ -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<string>
|
||||
// 按笔记身份修改占位,上传结束时该笔记可能已切到后台
|
||||
replace: (text: string, replacement: string) => void
|
||||
importImage: (url: string) => Promise<string>
|
||||
// 按笔记身份更新,异步粘贴或上传结束时该笔记可能已切到后台。
|
||||
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<PasteImagesOptions, PasteImagesOptions | undefined>({ combine: (v) => v[0] })
|
||||
|
||||
const pasteRange = StateEffect.define<{ id: object; from: number; to: number } | { id: object }>()
|
||||
const pendingPastes = StateField.define<Map<object, { from: number; to: number }>>({
|
||||
create: () => new Map(),
|
||||
update(value, transaction) {
|
||||
const next = new Map<object, { from: number; to: number }>()
|
||||
let completed: object | undefined
|
||||
for (const effect of transaction.effects) if (effect.is(pasteRange) && !('from' in effect.value)) completed = effect.value.id
|
||||
let afterCompleted = false
|
||||
for (const [id, range] of value) {
|
||||
if (id === completed) { afterCompleted = true; continue }
|
||||
let edited = transaction.isUserEvent('undo')
|
||||
transaction.changes.iterChangedRanges((from, to) => {
|
||||
if (from < range.to && to > range.from || from === to && from > range.from && from < range.to) edited = true
|
||||
})
|
||||
// 用户在等待期间编辑或撤销了粘贴内容,迟到的转换结果不能覆盖输入。
|
||||
// 同一位置连续粘贴时按发起顺序定位,与图片下载完成的先后无关。
|
||||
const association = range.from === range.to ? (afterCompleted ? 1 : -1) : 1
|
||||
if (!edited) next.set(id, { from: transaction.changes.mapPos(range.from, association), to: transaction.changes.mapPos(range.to, range.from === range.to ? association : -1) })
|
||||
}
|
||||
for (const effect of transaction.effects) if (effect.is(pasteRange)) {
|
||||
if ('from' in effect.value) next.set(effect.value.id, { from: effect.value.from, to: effect.value.to })
|
||||
else next.delete(effect.value.id)
|
||||
}
|
||||
return next
|
||||
},
|
||||
})
|
||||
|
||||
function replaceText(opts: PasteImagesOptions, text: string, replacement: string) {
|
||||
opts.update((state) => {
|
||||
const at = state.doc.toString().indexOf(text)
|
||||
if (at >= 0) return { changes: { from: at, to: at + text.length, insert: replacement } }
|
||||
})
|
||||
}
|
||||
|
||||
function insertHTML(view: EditorView, html: string, text: string, opts: PasteImagesOptions) {
|
||||
const id = {}
|
||||
const range = view.state.selection.main
|
||||
opts.onPending(1)
|
||||
view.dispatch({ effects: pasteRange.of({ id, from: range.from, to: range.to }) })
|
||||
const insert = (markdown: string) => {
|
||||
opts.update((state) => {
|
||||
const pending = state.field(pendingPastes).get(id)
|
||||
if (pending) return {
|
||||
changes: { from: pending.from, to: pending.to, insert: markdown },
|
||||
selection: state.selection.main.from === pending.from && state.selection.main.to === pending.to ? { anchor: pending.from + markdown.length } : undefined,
|
||||
effects: pasteRange.of({ id }),
|
||||
userEvent: 'input.paste',
|
||||
annotations: isolateHistory.of('full'),
|
||||
}
|
||||
})
|
||||
}
|
||||
void import('./html-paste').then(({ htmlToMarkdown }) => htmlToMarkdown(html, opts.importImage, opts.onError)).catch((error) => {
|
||||
opts.onError(new Error('网页转换失败,已保留剪贴板纯文本', { cause: error }))
|
||||
return text
|
||||
}).then(insert).catch(opts.onError).finally(() => opts.onPending(-1))
|
||||
}
|
||||
|
||||
/**
|
||||
* 插入图片文件(粘贴、拖入、从相册选择共用):先用 blob: 地址插入占位,立即显示;上传完成后替换为正式链接。
|
||||
* 返回是否有图片被插入;编辑器未配置上传(pasteImages)时不处理
|
||||
@@ -35,9 +94,9 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
|
||||
opts.onPending(1)
|
||||
opts
|
||||
.upload(file)
|
||||
.then((link) => opts.replace(urls[i], link))
|
||||
.then((link) => replaceText(opts, urls[i], link))
|
||||
.catch((err) => {
|
||||
opts.replace(``, '')
|
||||
replaceText(opts, ``, '')
|
||||
opts.onError(err)
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -50,10 +109,22 @@ export function insertImageFiles(view: EditorView, files: File[], pos?: number):
|
||||
|
||||
// 粘贴或拖入图片
|
||||
export function pasteImages(opts: PasteImagesOptions): Extension {
|
||||
let plain = false
|
||||
return [
|
||||
uploadOptions.of(opts),
|
||||
pendingPastes,
|
||||
EditorView.domEventHandlers({
|
||||
keydown(e) { plain = e.shiftKey },
|
||||
keyup(e) { plain = e.shiftKey },
|
||||
blur() { plain = false },
|
||||
paste(e, view) {
|
||||
if (view.state.readOnly || plain) return false
|
||||
const html = e.clipboardData?.getData('text/html')
|
||||
if (html) {
|
||||
insertHTML(view, html, e.clipboardData!.getData('text/plain'), opts)
|
||||
e.preventDefault()
|
||||
return true
|
||||
}
|
||||
// 个别浏览器/来源只在 items 中提供图片
|
||||
let files = [...(e.clipboardData?.files ?? [])]
|
||||
if (!files.length) {
|
||||
@@ -64,6 +135,7 @@ export function pasteImages(opts: PasteImagesOptions): Extension {
|
||||
return true
|
||||
},
|
||||
drop(e, view) {
|
||||
if (view.state.readOnly) return false
|
||||
const files = [...(e.dataTransfer?.files ?? [])]
|
||||
const pos = view.posAtCoords(e)
|
||||
if (!files.length || !insertImageFiles(view, files, pos ?? undefined)) return false
|
||||
|
||||
Reference in new issue
Block a user