wrappages
This commit is contained in:
parent
20c4fef3c6
commit
2bdd16fa37
@ -53,15 +53,37 @@ type UltiDocPageBackground struct {
|
||||
GradientCss string `json:"gradientCss,omitempty"`
|
||||
}
|
||||
|
||||
type UltiDocPageHeaderFooter struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
HeightMm float64 `json:"heightMm,omitempty"`
|
||||
}
|
||||
|
||||
type UltiDocPageNumberSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Placement string `json:"placement"`
|
||||
Align string `json:"align"`
|
||||
StartAt int `json:"startAt"`
|
||||
ShowOnFirstPage bool `json:"showOnFirstPage"`
|
||||
}
|
||||
|
||||
type UltiDocPageSetup struct {
|
||||
WidthMm float64 `json:"widthMm"`
|
||||
HeightMm float64 `json:"heightMm"`
|
||||
MarginsMm UltiDocPageMargins `json:"marginsMm"`
|
||||
HeaderMarginMm float64 `json:"headerMarginMm,omitempty"`
|
||||
FooterMarginMm float64 `json:"footerMarginMm,omitempty"`
|
||||
FormatID string `json:"formatId,omitempty"`
|
||||
Orientation string `json:"orientation,omitempty"`
|
||||
PageColor string `json:"pageColor,omitempty"`
|
||||
PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"`
|
||||
Borders *UltiDocPageBorders `json:"borders,omitempty"`
|
||||
Header *UltiDocPageHeaderFooter `json:"header,omitempty"`
|
||||
Footer *UltiDocPageHeaderFooter `json:"footer,omitempty"`
|
||||
HeaderFirstPage *UltiDocPageHeaderFooter `json:"headerFirstPage,omitempty"`
|
||||
FooterFirstPage *UltiDocPageHeaderFooter `json:"footerFirstPage,omitempty"`
|
||||
HeaderFooterDifferentFirstPage bool `json:"headerFooterDifferentFirstPage,omitempty"`
|
||||
HeaderFooterDifferentOddEven bool `json:"headerFooterDifferentOddEven,omitempty"`
|
||||
PageNumbers *UltiDocPageNumberSettings `json:"pageNumbers,omitempty"`
|
||||
}
|
||||
|
||||
type UltiDocPageBorderSide struct {
|
||||
|
||||
@ -44,6 +44,7 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou
|
||||
pr.With(read).Post("/session", h.CreateSession)
|
||||
pr.With(read).Post("/import", h.Import)
|
||||
pr.With(read).Post("/export", h.Export)
|
||||
pr.With(write).Post("/assets", h.UploadAsset)
|
||||
pr.With(write).Put("/save", h.Save)
|
||||
})
|
||||
return r
|
||||
@ -307,6 +308,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
||||
doc := NewUltiDoc(payload.Document, nil)
|
||||
doc.YjsState = payload.YjsState
|
||||
preserveUltiDocMetadata(&doc, existing)
|
||||
if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
raw, err = doc.Marshal()
|
||||
if err != nil {
|
||||
@ -316,6 +321,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
||||
} else if payload.YjsState != "" {
|
||||
doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()}
|
||||
preserveUltiDocMetadata(&doc, existing)
|
||||
if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
var err error
|
||||
raw, err = doc.Marshal()
|
||||
if err != nil {
|
||||
@ -335,3 +344,34 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type assetUploadRequest struct {
|
||||
Path string `json:"path"`
|
||||
DataURL string `json:"dataUrl"`
|
||||
}
|
||||
|
||||
func (h *Handler) UploadAsset(w http.ResponseWriter, r *http.Request) {
|
||||
claims := middleware.ClaimsFromContext(r.Context())
|
||||
ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
|
||||
if err != nil {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
return
|
||||
}
|
||||
var req assetUploadRequest
|
||||
if err := apivalidate.DecodeJSON(w, r, 16<<20, &req); err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Path) == "" || strings.TrimSpace(req.DataURL) == "" {
|
||||
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
|
||||
apivalidate.FieldDetail{Field: "path", Message: "required"},
|
||||
apivalidate.FieldDetail{Field: "dataUrl", Message: "required"},
|
||||
))
|
||||
return
|
||||
}
|
||||
result, err := h.svc.UploadGraphicAsset(r.Context(), ncUser, req.Path, req.DataURL)
|
||||
if err != nil {
|
||||
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
apiresponse.WriteJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package richtext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -246,6 +248,7 @@ func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID str
|
||||
Path: source,
|
||||
ImportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
doc.YjsState = ""
|
||||
if len(req.PageSetup) > 0 {
|
||||
var pageSetup UltiDocPageSetup
|
||||
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
|
||||
@ -375,6 +378,64 @@ func htmlEscape(s string) string {
|
||||
return r.Replace(s)
|
||||
}
|
||||
|
||||
type GraphicAssetResult struct {
|
||||
AssetID string `json:"assetId"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UploadGraphicAsset stores a base64 data URL as a binary next to the document.
|
||||
func (s *Service) UploadGraphicAsset(ctx context.Context, ncUser, docPath, dataURL string) (*GraphicAssetResult, error) {
|
||||
docPath = normalizePath(docPath)
|
||||
comma := strings.Index(dataURL, ",")
|
||||
if comma < 0 {
|
||||
return nil, fmt.Errorf("invalid data URL")
|
||||
}
|
||||
meta := dataURL[:comma]
|
||||
payload := dataURL[comma+1:]
|
||||
mime := "application/octet-stream"
|
||||
if strings.Contains(meta, "image/png") {
|
||||
mime = "image/png"
|
||||
} else if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") {
|
||||
mime = "image/jpeg"
|
||||
} else if strings.Contains(meta, "image/webp") {
|
||||
mime = "image/webp"
|
||||
} else if strings.Contains(meta, "image/gif") {
|
||||
mime = "image/gif"
|
||||
}
|
||||
|
||||
raw, err := decodeBase64Payload(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
assetID := hashPath(docPath + ":" + payload[:min(32, len(payload))])
|
||||
dir := strings.TrimSuffix(docPath, "/")
|
||||
if idx := strings.LastIndex(dir, "/"); idx >= 0 {
|
||||
dir = dir[:idx]
|
||||
} else {
|
||||
dir = ""
|
||||
}
|
||||
assetPath := normalizePath(strings.Trim(dir+"/.ultidoc-assets/"+assetID, "/"))
|
||||
if err := s.nc.Upload(ctx, ncUser, assetPath, bytes.NewReader(raw), mime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GraphicAssetResult{
|
||||
AssetID: assetID,
|
||||
URL: "/api/v1/drive/files/" + assetPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeBase64Payload(payload string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(payload)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func hashPath(p string) string {
|
||||
h := sha256Hex([]byte(normalizePath(p)))
|
||||
if len(h) > 16 {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "^4.1.0",
|
||||
"@hocuspocus/transformer": "^4.1.0",
|
||||
"@tiptap/core": "^3.23.2",
|
||||
"@tiptap/extension-highlight": "^3.23.2",
|
||||
"@tiptap/extension-image": "^3.23.2",
|
||||
"@tiptap/extension-link": "^3.23.2",
|
||||
|
||||
@ -14,6 +14,9 @@ importers:
|
||||
'@hocuspocus/transformer':
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(y-prosemirror@1.3.7(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)
|
||||
'@tiptap/core':
|
||||
specifier: ^3.23.2
|
||||
version: 3.26.0(@tiptap/pm@3.26.0)
|
||||
'@tiptap/extension-highlight':
|
||||
specifier: ^3.23.2
|
||||
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
|
||||
|
||||
@ -5,6 +5,7 @@ import { createRequire } from "node:module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const { Node } = require("@tiptap/core")
|
||||
const StarterKit = require("@tiptap/starter-kit").default
|
||||
const Underline = require("@tiptap/extension-underline").default
|
||||
const Link = require("@tiptap/extension-link").default
|
||||
@ -14,9 +15,59 @@ const TextAlign = require("@tiptap/extension-text-align").default
|
||||
const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
|
||||
const Image = require("@tiptap/extension-image").default
|
||||
|
||||
const graphicAttributes = {
|
||||
graphicType: { default: "image" },
|
||||
src: { default: null },
|
||||
alt: { default: "" },
|
||||
shapeType: { default: "rect" },
|
||||
fill: { default: "#4285f4" },
|
||||
stroke: { default: "#1a73e8" },
|
||||
strokeWidth: { default: 2 },
|
||||
gradientCss: { default: "" },
|
||||
gradientAngle: { default: 180 },
|
||||
gradientColor1: { default: "#4285f4" },
|
||||
gradientColor2: { default: "#34a853" },
|
||||
width: { default: 240 },
|
||||
height: { default: 160 },
|
||||
placement: { default: "block" },
|
||||
wrap: { default: "square" },
|
||||
floatSide: { default: "left" },
|
||||
x: { default: 0 },
|
||||
y: { default: 0 },
|
||||
rotationDeg: { default: 0 },
|
||||
zIndex: { default: 0 },
|
||||
cropX: { default: 0 },
|
||||
cropY: { default: 0 },
|
||||
cropWidth: { default: 1 },
|
||||
cropHeight: { default: 1 },
|
||||
cropShape: { default: "rect" },
|
||||
assetId: { default: null },
|
||||
opacity: { default: 1 },
|
||||
shadow: { default: "" },
|
||||
}
|
||||
|
||||
const DocsGraphic = Node.create({
|
||||
name: "docsGraphic",
|
||||
group: "block",
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return graphicAttributes
|
||||
},
|
||||
})
|
||||
|
||||
const DocsInlineGraphic = Node.create({
|
||||
name: "docsInlineGraphic",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
addAttributes() {
|
||||
return graphicAttributes
|
||||
},
|
||||
})
|
||||
|
||||
/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */
|
||||
const transformerExtensions = [
|
||||
StarterKit.configure({ undoRedo: false }),
|
||||
StarterKit.configure({ undoRedo: false, link: false, underline: false }),
|
||||
Underline,
|
||||
Link.configure({ openOnClick: false }),
|
||||
TextStyle,
|
||||
@ -27,6 +78,8 @@ const transformerExtensions = [
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Image.configure({ inline: true, allowBase64: true }),
|
||||
DocsGraphic,
|
||||
DocsInlineGraphic,
|
||||
]
|
||||
|
||||
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
|
||||
@ -61,6 +114,23 @@ function tipTapContentHasText(content) {
|
||||
return walk(content)
|
||||
}
|
||||
|
||||
function tipTapContentHasBody(content) {
|
||||
if (!content || typeof content !== "object") return false
|
||||
if (tipTapContentHasText(content)) return true
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== "object") return false
|
||||
const type = node.type
|
||||
if (type && type !== "doc" && type !== "paragraph") return true
|
||||
if (Array.isArray(node.content)) {
|
||||
if (node.content.length === 0) return false
|
||||
if (node.content.length > 1) return true
|
||||
return walk(node.content[0])
|
||||
}
|
||||
return false
|
||||
}
|
||||
return walk(content)
|
||||
}
|
||||
|
||||
async function loadFromUltid(context) {
|
||||
if (!context?.path || !context?.user) return null
|
||||
const params = new URLSearchParams({ user: context.user, path: context.path })
|
||||
@ -73,12 +143,12 @@ async function loadFromUltid(context) {
|
||||
if (!raw.trim()) return null
|
||||
try {
|
||||
const doc = JSON.parse(raw)
|
||||
const hasStoredText = tipTapContentHasText(doc.content)
|
||||
if (doc.content && hasStoredText) {
|
||||
const hasStoredBody = tipTapContentHasBody(doc.content)
|
||||
if (doc.content && hasStoredBody) {
|
||||
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
|
||||
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
|
||||
}
|
||||
if (doc.yjsState) {
|
||||
if (doc.yjsState && !hasStoredBody) {
|
||||
return Buffer.from(doc.yjsState, "base64")
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user