wrappages
This commit is contained in:
parent
20c4fef3c6
commit
2bdd16fa37
@ -53,15 +53,37 @@ type UltiDocPageBackground struct {
|
|||||||
GradientCss string `json:"gradientCss,omitempty"`
|
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 {
|
type UltiDocPageSetup struct {
|
||||||
WidthMm float64 `json:"widthMm"`
|
WidthMm float64 `json:"widthMm"`
|
||||||
HeightMm float64 `json:"heightMm"`
|
HeightMm float64 `json:"heightMm"`
|
||||||
MarginsMm UltiDocPageMargins `json:"marginsMm"`
|
MarginsMm UltiDocPageMargins `json:"marginsMm"`
|
||||||
|
HeaderMarginMm float64 `json:"headerMarginMm,omitempty"`
|
||||||
|
FooterMarginMm float64 `json:"footerMarginMm,omitempty"`
|
||||||
FormatID string `json:"formatId,omitempty"`
|
FormatID string `json:"formatId,omitempty"`
|
||||||
Orientation string `json:"orientation,omitempty"`
|
Orientation string `json:"orientation,omitempty"`
|
||||||
PageColor string `json:"pageColor,omitempty"`
|
PageColor string `json:"pageColor,omitempty"`
|
||||||
PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"`
|
PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"`
|
||||||
Borders *UltiDocPageBorders `json:"borders,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 {
|
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("/session", h.CreateSession)
|
||||||
pr.With(read).Post("/import", h.Import)
|
pr.With(read).Post("/import", h.Import)
|
||||||
pr.With(read).Post("/export", h.Export)
|
pr.With(read).Post("/export", h.Export)
|
||||||
|
pr.With(write).Post("/assets", h.UploadAsset)
|
||||||
pr.With(write).Put("/save", h.Save)
|
pr.With(write).Put("/save", h.Save)
|
||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
@ -307,6 +308,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
|||||||
doc := NewUltiDoc(payload.Document, nil)
|
doc := NewUltiDoc(payload.Document, nil)
|
||||||
doc.YjsState = payload.YjsState
|
doc.YjsState = payload.YjsState
|
||||||
preserveUltiDocMetadata(&doc, existing)
|
preserveUltiDocMetadata(&doc, existing)
|
||||||
|
if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
raw, err = doc.Marshal()
|
raw, err = doc.Marshal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -316,6 +321,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else if payload.YjsState != "" {
|
} else if payload.YjsState != "" {
|
||||||
doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()}
|
doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()}
|
||||||
preserveUltiDocMetadata(&doc, existing)
|
preserveUltiDocMetadata(&doc, existing)
|
||||||
|
if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
var err error
|
var err error
|
||||||
raw, err = doc.Marshal()
|
raw, err = doc.Marshal()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -335,3 +344,34 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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
|
package richtext
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@ -246,6 +248,7 @@ func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID str
|
|||||||
Path: source,
|
Path: source,
|
||||||
ImportedAt: time.Now().UTC().Format(time.RFC3339),
|
ImportedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
|
doc.YjsState = ""
|
||||||
if len(req.PageSetup) > 0 {
|
if len(req.PageSetup) > 0 {
|
||||||
var pageSetup UltiDocPageSetup
|
var pageSetup UltiDocPageSetup
|
||||||
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
|
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
|
||||||
@ -375,6 +378,64 @@ func htmlEscape(s string) string {
|
|||||||
return r.Replace(s)
|
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 {
|
func hashPath(p string) string {
|
||||||
h := sha256Hex([]byte(normalizePath(p)))
|
h := sha256Hex([]byte(normalizePath(p)))
|
||||||
if len(h) > 16 {
|
if len(h) > 16 {
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hocuspocus/server": "^4.1.0",
|
"@hocuspocus/server": "^4.1.0",
|
||||||
"@hocuspocus/transformer": "^4.1.0",
|
"@hocuspocus/transformer": "^4.1.0",
|
||||||
|
"@tiptap/core": "^3.23.2",
|
||||||
"@tiptap/extension-highlight": "^3.23.2",
|
"@tiptap/extension-highlight": "^3.23.2",
|
||||||
"@tiptap/extension-image": "^3.23.2",
|
"@tiptap/extension-image": "^3.23.2",
|
||||||
"@tiptap/extension-link": "^3.23.2",
|
"@tiptap/extension-link": "^3.23.2",
|
||||||
|
|||||||
@ -14,6 +14,9 @@ importers:
|
|||||||
'@hocuspocus/transformer':
|
'@hocuspocus/transformer':
|
||||||
specifier: ^4.1.0
|
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)
|
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':
|
'@tiptap/extension-highlight':
|
||||||
specifier: ^3.23.2
|
specifier: ^3.23.2
|
||||||
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
|
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 require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const { Node } = require("@tiptap/core")
|
||||||
const StarterKit = require("@tiptap/starter-kit").default
|
const StarterKit = require("@tiptap/starter-kit").default
|
||||||
const Underline = require("@tiptap/extension-underline").default
|
const Underline = require("@tiptap/extension-underline").default
|
||||||
const Link = require("@tiptap/extension-link").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 { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
|
||||||
const Image = require("@tiptap/extension-image").default
|
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. */
|
/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */
|
||||||
const transformerExtensions = [
|
const transformerExtensions = [
|
||||||
StarterKit.configure({ undoRedo: false }),
|
StarterKit.configure({ undoRedo: false, link: false, underline: false }),
|
||||||
Underline,
|
Underline,
|
||||||
Link.configure({ openOnClick: false }),
|
Link.configure({ openOnClick: false }),
|
||||||
TextStyle,
|
TextStyle,
|
||||||
@ -27,6 +78,8 @@ const transformerExtensions = [
|
|||||||
TableCell,
|
TableCell,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
Image.configure({ inline: true, allowBase64: true }),
|
Image.configure({ inline: true, allowBase64: true }),
|
||||||
|
DocsGraphic,
|
||||||
|
DocsInlineGraphic,
|
||||||
]
|
]
|
||||||
|
|
||||||
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
|
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
|
||||||
@ -61,6 +114,23 @@ function tipTapContentHasText(content) {
|
|||||||
return walk(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) {
|
async function loadFromUltid(context) {
|
||||||
if (!context?.path || !context?.user) return null
|
if (!context?.path || !context?.user) return null
|
||||||
const params = new URLSearchParams({ user: context.user, path: context.path })
|
const params = new URLSearchParams({ user: context.user, path: context.path })
|
||||||
@ -73,12 +143,12 @@ async function loadFromUltid(context) {
|
|||||||
if (!raw.trim()) return null
|
if (!raw.trim()) return null
|
||||||
try {
|
try {
|
||||||
const doc = JSON.parse(raw)
|
const doc = JSON.parse(raw)
|
||||||
const hasStoredText = tipTapContentHasText(doc.content)
|
const hasStoredBody = tipTapContentHasBody(doc.content)
|
||||||
if (doc.content && hasStoredText) {
|
if (doc.content && hasStoredBody) {
|
||||||
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
|
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
|
||||||
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
|
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
|
||||||
}
|
}
|
||||||
if (doc.yjsState) {
|
if (doc.yjsState && !hasStoredBody) {
|
||||||
return Buffer.from(doc.yjsState, "base64")
|
return Buffer.from(doc.yjsState, "base64")
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user