feat: implement unified editor with content persistence and server-side upsert
- Replace dual update systems with single markdown-first editor architecture - Add server-side upsert to eliminate 404 errors on PUT operations - Fix content persistence race condition between preview and save operations - Remove legacy updateElementContent system entirely - Add comprehensive authentication with JWT scaffolding and dev mode - Implement EditContext.updateOriginalContent() for proper baseline management - Enable markdown formatting in all text elements (h1-h6, p, div, etc) - Clean terminology: remove 'unified' references from codebase Technical changes: * core/editor.js: Remove legacy update system, unify content types as markdown * ui/Editor.js: Add updateOriginalContent() method to fix save persistence * ui/Previewer.js: Clean live preview system for all content types * api/handlers.go: Implement UpsertContent for idempotent PUT operations * auth/*: Complete authentication service with OAuth scaffolding * db/queries/content.sql: Add upsert query with ON CONFLICT handling * Schema: Remove type constraints, rely on server-side validation Result: Clean content editing with persistent saves, no 404 errors, markdown support in all text elements
This commit is contained in:
31
AGENTS.md
Normal file
31
AGENTS.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# AGENTS.md - Developer Guide for Insertr
|
||||||
|
|
||||||
|
## Build/Test Commands
|
||||||
|
- `just dev` - Full-stack development (recommended)
|
||||||
|
- `just build` - Build entire project (Go binary + JS library)
|
||||||
|
- `just build-lib` - Build JS library only
|
||||||
|
- `just test` - Run tests (placeholder, no actual tests yet)
|
||||||
|
- `just lint` - Run linting (placeholder, no actual linting yet)
|
||||||
|
- `just air` - Hot reload Go backend only
|
||||||
|
- `go test ./...` - Run Go tests (when available)
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Go (Backend)
|
||||||
|
- Use standard Go formatting (`gofmt`)
|
||||||
|
- Package imports: stdlib → third-party → internal
|
||||||
|
- Error handling: always check and handle errors
|
||||||
|
- Naming: camelCase for local vars, PascalCase for exported
|
||||||
|
- Comments: package-level comments, exported functions documented
|
||||||
|
|
||||||
|
### JavaScript (Frontend)
|
||||||
|
- ES6+ modules, no CommonJS
|
||||||
|
- camelCase for variables/functions, PascalCase for classes
|
||||||
|
- Use `const`/`let`, avoid `var`
|
||||||
|
- Prefer template literals over string concatenation
|
||||||
|
- Export classes/functions explicitly, avoid default exports when multiple exports exist
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- Use `sqlc` for Go database code generation
|
||||||
|
- SQL files in `db/queries/`, schemas in `db/{sqlite,postgresql}/schema.sql`
|
||||||
|
- Run `just sqlc` after schema changes
|
||||||
120
TODO.md
120
TODO.md
@@ -242,3 +242,123 @@ CREATE INDEX IF NOT EXISTS idx_content_site_id ON content(site_id);
|
|||||||
- `db/*/setup.sql` - Contains table creation queries (sqlc generates type-safe functions)
|
- `db/*/setup.sql` - Contains table creation queries (sqlc generates type-safe functions)
|
||||||
- `internal/db/database.go` - Manual index/trigger creation using raw SQL
|
- `internal/db/database.go` - Manual index/trigger creation using raw SQL
|
||||||
- **Best Practice**: Use sqlc for what it supports, manual SQL for what it doesn't
|
- **Best Practice**: Use sqlc for what it supports, manual SQL for what it doesn't
|
||||||
|
|
||||||
|
## 🔄 **Editor Cache Architecture & Content State Management** (Dec 2024)
|
||||||
|
|
||||||
|
### **Current Architecture Issues Identified**
|
||||||
|
|
||||||
|
**Problem**: Conflict between preview system and content persistence after save operations.
|
||||||
|
|
||||||
|
**Root Cause**: The unified Editor system was designed with preview-first architecture:
|
||||||
|
- `originalContent` stores DOM state when editing begins
|
||||||
|
- `clearPreview()` always restores to `originalContent`
|
||||||
|
- This creates race condition: `applyContent()` → `clearPreview()` → content reverted
|
||||||
|
|
||||||
|
### **✅ Implemented Solution: Post-Save Baseline Update**
|
||||||
|
|
||||||
|
**Strategy**: After successful save, update the stored `originalContent` to match the new saved state.
|
||||||
|
|
||||||
|
**Implementation** (`lib/src/ui/Editor.js`):
|
||||||
|
```js
|
||||||
|
// In save handler:
|
||||||
|
context.applyContent(content); // Apply new content to DOM
|
||||||
|
context.updateOriginalContent(); // Update baseline to match current DOM
|
||||||
|
this.previewer.clearPreview(); // Clear preview (won't revert since baseline matches)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- ✅ Content persists after save operations
|
||||||
|
- ✅ Future cancellations restore to saved state, not pre-edit state
|
||||||
|
- ✅ Maintains clean preview functionality
|
||||||
|
- ✅ No breaking changes to existing architecture
|
||||||
|
|
||||||
|
### **Future Considerations: Draft System Architecture**
|
||||||
|
|
||||||
|
**Current State Management**:
|
||||||
|
- **Browser Cache**: DOM elements store current content state
|
||||||
|
- **Server Cache**: Database stores persisted content
|
||||||
|
- **No Intermediate Cache**: Edits are either preview (temporary) or saved (permanent)
|
||||||
|
|
||||||
|
**Potential Draft System Design**:
|
||||||
|
|
||||||
|
#### **Option 1: LocalStorage Drafts**
|
||||||
|
```js
|
||||||
|
// Auto-save drafts locally during editing
|
||||||
|
const draftKey = `insertr_draft_${contentId}_${siteId}`;
|
||||||
|
localStorage.setItem(draftKey, JSON.stringify({
|
||||||
|
content: currentContent,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
originalContent: baseline
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**: Offline support, immediate feedback, no server load
|
||||||
|
**Drawbacks**: Per-browser, no cross-device sync, storage limits
|
||||||
|
|
||||||
|
#### **Option 2: Server-Side Drafts**
|
||||||
|
```js
|
||||||
|
// Auto-save drafts to server with special draft flag
|
||||||
|
PUT /api/content/{id}/draft
|
||||||
|
{
|
||||||
|
"value": "Draft content...",
|
||||||
|
"type": "markdown",
|
||||||
|
"is_draft": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**: Cross-device sync, unlimited storage, collaborative editing potential
|
||||||
|
**Drawbacks**: Server complexity, authentication requirements, network dependency
|
||||||
|
|
||||||
|
#### **Option 3: Hybrid Draft System**
|
||||||
|
- **LocalStorage**: Immediate draft saving during typing
|
||||||
|
- **Server Sync**: Periodic sync of drafts (every 30s or on significant changes)
|
||||||
|
- **Conflict Resolution**: Handle cases where server content changed while editing draft
|
||||||
|
|
||||||
|
### **Cache Invalidation Strategy**
|
||||||
|
|
||||||
|
**Current Behavior**:
|
||||||
|
- Content is cached in DOM until page reload
|
||||||
|
- No automatic refresh when content changes on server
|
||||||
|
- No awareness of multi-user editing scenarios
|
||||||
|
|
||||||
|
**Recommended Enhancements**:
|
||||||
|
|
||||||
|
#### **Option A: Manual Refresh Strategy**
|
||||||
|
- Add "Refresh Content" button to editor interface
|
||||||
|
- Check server content before editing (warn if changed)
|
||||||
|
- Simple conflict resolution (server wins vs local wins vs merge)
|
||||||
|
|
||||||
|
#### **Option B: Polling Strategy**
|
||||||
|
- Poll server every N minutes for content changes
|
||||||
|
- Show notification if content was updated by others
|
||||||
|
- Allow user to choose: keep editing, reload, or merge
|
||||||
|
|
||||||
|
#### **Option C: WebSocket Strategy**
|
||||||
|
- Real-time content change notifications
|
||||||
|
- Live collaborative editing indicators
|
||||||
|
- Automatic conflict resolution
|
||||||
|
|
||||||
|
### **Implementation Priority**
|
||||||
|
|
||||||
|
**Phase 1** (Immediate): ✅ **COMPLETED**
|
||||||
|
- Fix content persistence after save (baseline update approach)
|
||||||
|
|
||||||
|
**Phase 2** (Short-term):
|
||||||
|
- Add LocalStorage draft auto-save during editing
|
||||||
|
- Implement draft recovery on page reload
|
||||||
|
- Basic conflict detection (server timestamp vs local timestamp)
|
||||||
|
|
||||||
|
**Phase 3** (Long-term):
|
||||||
|
- Server-side draft support
|
||||||
|
- Real-time collaboration features
|
||||||
|
- Advanced conflict resolution
|
||||||
|
|
||||||
|
### **Design Principles for Draft System**
|
||||||
|
|
||||||
|
1. **Progressive Enhancement**: Site works without drafts, drafts enhance UX
|
||||||
|
2. **Data Safety**: Never lose user content, even in edge cases
|
||||||
|
3. **Performance First**: Drafts shouldn't impact site loading for regular visitors
|
||||||
|
4. **Conflict Transparency**: Always show user what's happening with their content
|
||||||
|
5. **Graceful Degradation**: Fallback to basic editing if draft system fails
|
||||||
|
|
||||||
|
**Note**: Current architecture already supports foundation for all these enhancements through the unified Editor system and API client pattern.
|
||||||
|
|||||||
19
cmd/serve.go
19
cmd/serve.go
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
"github.com/insertr/insertr/internal/api"
|
"github.com/insertr/insertr/internal/api"
|
||||||
|
"github.com/insertr/insertr/internal/auth"
|
||||||
"github.com/insertr/insertr/internal/db"
|
"github.com/insertr/insertr/internal/db"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,8 +52,24 @@ func runServe(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
defer database.Close()
|
defer database.Close()
|
||||||
|
|
||||||
|
// Initialize authentication service
|
||||||
|
authConfig := &auth.AuthConfig{
|
||||||
|
DevMode: viper.GetBool("dev_mode"),
|
||||||
|
JWTSecret: viper.GetString("jwt_secret"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default JWT secret if not configured
|
||||||
|
if authConfig.JWTSecret == "" {
|
||||||
|
authConfig.JWTSecret = "dev-secret-change-in-production"
|
||||||
|
if authConfig.DevMode {
|
||||||
|
log.Printf("🔑 Using default JWT secret for development")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
authService := auth.NewAuthService(authConfig)
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
contentHandler := api.NewContentHandler(database)
|
contentHandler := api.NewContentHandler(database, authService)
|
||||||
|
|
||||||
// Setup router
|
// Setup router
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ CREATE TABLE content (
|
|||||||
id TEXT NOT NULL,
|
id TEXT NOT NULL,
|
||||||
site_id TEXT NOT NULL,
|
site_id TEXT NOT NULL,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
|
type TEXT NOT NULL,
|
||||||
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
|
created_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
|
||||||
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
|
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) NOT NULL,
|
||||||
last_edited_by TEXT DEFAULT 'system' NOT NULL,
|
last_edited_by TEXT DEFAULT 'system' NOT NULL,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ SET value = sqlc.arg(value), type = sqlc.arg(type), last_edited_by = sqlc.arg(la
|
|||||||
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id)
|
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id)
|
||||||
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
|
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
|
||||||
|
|
||||||
|
-- name: UpsertContent :one
|
||||||
|
INSERT INTO content (id, site_id, value, type, last_edited_by)
|
||||||
|
VALUES (sqlc.arg(id), sqlc.arg(site_id), sqlc.arg(value), sqlc.arg(type), sqlc.arg(last_edited_by))
|
||||||
|
ON CONFLICT(id, site_id) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
type = EXCLUDED.type,
|
||||||
|
last_edited_by = EXCLUDED.last_edited_by
|
||||||
|
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by;
|
||||||
|
|
||||||
-- name: DeleteContent :exec
|
-- name: DeleteContent :exec
|
||||||
DELETE FROM content
|
DELETE FROM content
|
||||||
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
|
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
|
||||||
@@ -4,7 +4,7 @@ CREATE TABLE content (
|
|||||||
id TEXT NOT NULL,
|
id TEXT NOT NULL,
|
||||||
site_id TEXT NOT NULL,
|
site_id TEXT NOT NULL,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
type TEXT NOT NULL CHECK (type IN ('text', 'markdown', 'link')),
|
type TEXT NOT NULL,
|
||||||
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
||||||
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
||||||
last_edited_by TEXT DEFAULT 'system' NOT NULL,
|
last_edited_by TEXT DEFAULT 'system' NOT NULL,
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -7,6 +7,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/insertr/insertr/internal/auth"
|
||||||
"github.com/insertr/insertr/internal/db"
|
"github.com/insertr/insertr/internal/db"
|
||||||
"github.com/insertr/insertr/internal/db/postgresql"
|
"github.com/insertr/insertr/internal/db/postgresql"
|
||||||
"github.com/insertr/insertr/internal/db/sqlite"
|
"github.com/insertr/insertr/internal/db/sqlite"
|
||||||
@@ -19,12 +20,14 @@ import (
|
|||||||
// ContentHandler handles all content-related HTTP requests
|
// ContentHandler handles all content-related HTTP requests
|
||||||
type ContentHandler struct {
|
type ContentHandler struct {
|
||||||
database *db.Database
|
database *db.Database
|
||||||
|
authService *auth.AuthService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewContentHandler creates a new content handler
|
// NewContentHandler creates a new content handler
|
||||||
func NewContentHandler(database *db.Database) *ContentHandler {
|
func NewContentHandler(database *db.Database, authService *auth.AuthService) *ContentHandler {
|
||||||
return &ContentHandler{
|
return &ContentHandler{
|
||||||
database: database,
|
database: database,
|
||||||
|
authService: authService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,14 +176,13 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
siteID = "default" // final fallback
|
siteID = "default" // final fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user from request (for now, use X-User-ID header or fallback)
|
// Extract user from request using authentication service
|
||||||
userID := r.Header.Get("X-User-ID")
|
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||||
if userID == "" && req.CreatedBy != "" {
|
if authErr != nil {
|
||||||
userID = req.CreatedBy
|
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||||
}
|
return
|
||||||
if userID == "" {
|
|
||||||
userID = "anonymous"
|
|
||||||
}
|
}
|
||||||
|
userID := userInfo.ID
|
||||||
|
|
||||||
var content interface{}
|
var content interface{}
|
||||||
var err error
|
var err error
|
||||||
@@ -219,7 +221,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
json.NewEncoder(w).Encode(item)
|
json.NewEncoder(w).Encode(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateContent handles PUT /api/content/{id}
|
// UpdateContent handles PUT /api/content/{id} with upsert functionality
|
||||||
func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
contentID := vars["id"]
|
contentID := vars["id"]
|
||||||
@@ -236,29 +238,70 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user from request
|
// Extract user from request using authentication service
|
||||||
userID := r.Header.Get("X-User-ID")
|
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||||
if userID == "" && req.UpdatedBy != "" {
|
if authErr != nil {
|
||||||
userID = req.UpdatedBy
|
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if userID == "" {
|
userID := userInfo.ID
|
||||||
userID = "anonymous"
|
|
||||||
|
// Check if content exists for version history (non-blocking)
|
||||||
|
var existingContent interface{}
|
||||||
|
var contentExists bool
|
||||||
|
|
||||||
|
switch h.database.GetDBType() {
|
||||||
|
case "sqlite3":
|
||||||
|
existingContent, _ = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
|
||||||
|
ID: contentID,
|
||||||
|
SiteID: siteID,
|
||||||
|
})
|
||||||
|
contentExists = existingContent != nil
|
||||||
|
case "postgresql":
|
||||||
|
existingContent, _ = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
|
||||||
|
ID: contentID,
|
||||||
|
SiteID: siteID,
|
||||||
|
})
|
||||||
|
contentExists = existingContent != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current content for version history and type preservation
|
// Archive existing version before upsert (only if content already exists)
|
||||||
var currentContent interface{}
|
if contentExists {
|
||||||
|
if err := h.createContentVersion(existingContent); err != nil {
|
||||||
|
// Log error but don't fail the request - version history is non-critical
|
||||||
|
fmt.Printf("Warning: Failed to create content version: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine content type: use provided type, fallback to existing type, default to "text"
|
||||||
|
contentType := req.Type
|
||||||
|
if contentType == "" && contentExists {
|
||||||
|
contentType = h.getContentType(existingContent)
|
||||||
|
}
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "text" // default type for new content
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform upsert operation
|
||||||
|
var upsertedContent interface{}
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
switch h.database.GetDBType() {
|
switch h.database.GetDBType() {
|
||||||
case "sqlite3":
|
case "sqlite3":
|
||||||
currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
|
upsertedContent, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{
|
||||||
ID: contentID,
|
ID: contentID,
|
||||||
SiteID: siteID,
|
SiteID: siteID,
|
||||||
|
Value: req.Value,
|
||||||
|
Type: contentType,
|
||||||
|
LastEditedBy: userID,
|
||||||
})
|
})
|
||||||
case "postgresql":
|
case "postgresql":
|
||||||
currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
|
upsertedContent, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{
|
||||||
ID: contentID,
|
ID: contentID,
|
||||||
SiteID: siteID,
|
SiteID: siteID,
|
||||||
|
Value: req.Value,
|
||||||
|
Type: contentType,
|
||||||
|
LastEditedBy: userID,
|
||||||
})
|
})
|
||||||
default:
|
default:
|
||||||
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
|
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
|
||||||
@@ -266,58 +309,11 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
http.Error(w, fmt.Sprintf("Failed to upsert content: %v", err), http.StatusInternalServerError)
|
||||||
http.Error(w, "Content not found", http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Archive current version before updating
|
item := h.convertToAPIContent(upsertedContent)
|
||||||
err = h.createContentVersion(currentContent)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to create version: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine content type
|
|
||||||
contentType := req.Type
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = h.getContentType(currentContent) // preserve existing type if not specified
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the content
|
|
||||||
var updatedContent interface{}
|
|
||||||
|
|
||||||
switch h.database.GetDBType() {
|
|
||||||
case "sqlite3":
|
|
||||||
updatedContent, err = h.database.GetSQLiteQueries().UpdateContent(context.Background(), sqlite.UpdateContentParams{
|
|
||||||
Value: req.Value,
|
|
||||||
Type: contentType,
|
|
||||||
LastEditedBy: userID,
|
|
||||||
ID: contentID,
|
|
||||||
SiteID: siteID,
|
|
||||||
})
|
|
||||||
case "postgresql":
|
|
||||||
updatedContent, err = h.database.GetPostgreSQLQueries().UpdateContent(context.Background(), postgresql.UpdateContentParams{
|
|
||||||
Value: req.Value,
|
|
||||||
Type: contentType,
|
|
||||||
LastEditedBy: userID,
|
|
||||||
ID: contentID,
|
|
||||||
SiteID: siteID,
|
|
||||||
})
|
|
||||||
default:
|
|
||||||
http.Error(w, "Unsupported database type", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, fmt.Sprintf("Failed to update content: %v", err), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
item := h.convertToAPIContent(updatedContent)
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(item)
|
json.NewEncoder(w).Encode(item)
|
||||||
@@ -459,14 +455,13 @@ func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract user from request
|
// Extract user from request using authentication service
|
||||||
userID := r.Header.Get("X-User-ID")
|
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||||
if userID == "" && req.RolledBackBy != "" {
|
if authErr != nil {
|
||||||
userID = req.RolledBackBy
|
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||||
}
|
return
|
||||||
if userID == "" {
|
|
||||||
userID = "anonymous"
|
|
||||||
}
|
}
|
||||||
|
userID := userInfo.ID
|
||||||
|
|
||||||
// Archive current version before rollback
|
// Archive current version before rollback
|
||||||
var currentContent interface{}
|
var currentContent interface{}
|
||||||
|
|||||||
265
internal/auth/auth.go
Normal file
265
internal/auth/auth.go
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserInfo represents authenticated user information
|
||||||
|
type UserInfo struct {
|
||||||
|
ID string `json:"sub"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Provider string `json:"iss,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthConfig holds authentication configuration
|
||||||
|
type AuthConfig struct {
|
||||||
|
DevMode bool
|
||||||
|
JWTSecret string
|
||||||
|
OAuthConfigs map[string]OAuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthConfig holds OAuth provider configuration
|
||||||
|
type OAuthConfig struct {
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
RedirectURL string
|
||||||
|
Scopes []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthService handles authentication operations
|
||||||
|
type AuthService struct {
|
||||||
|
config *AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAuthService creates a new authentication service
|
||||||
|
func NewAuthService(config *AuthConfig) *AuthService {
|
||||||
|
return &AuthService{config: config}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractUserFromRequest extracts user information from HTTP request
|
||||||
|
func (a *AuthService) ExtractUserFromRequest(r *http.Request) (*UserInfo, error) {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
return &UserInfo{ID: "anonymous"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Bearer token
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
return nil, fmt.Errorf("invalid authorization header format")
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
// Handle mock tokens in development mode
|
||||||
|
if a.config.DevMode && strings.HasPrefix(token, "mock-") {
|
||||||
|
return a.parseMockToken(token), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse real JWT token
|
||||||
|
return a.parseJWT(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMockToken handles mock development tokens
|
||||||
|
func (a *AuthService) parseMockToken(token string) *UserInfo {
|
||||||
|
// Mock token format: mock-{user}-{timestamp}-{random}
|
||||||
|
parts := strings.Split(token, "-")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return &UserInfo{
|
||||||
|
ID: parts[1], // user part
|
||||||
|
Email: fmt.Sprintf("%s@localhost", parts[1]),
|
||||||
|
Name: strings.Title(parts[1]),
|
||||||
|
Provider: "insertr-dev",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &UserInfo{ID: "anonymous"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseJWT parses and validates a real JWT token
|
||||||
|
func (a *AuthService) parseJWT(tokenString string) (*UserInfo, error) {
|
||||||
|
// Parse the token
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
// Validate signing method
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
|
return []byte(a.config.JWTSecret), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract claims
|
||||||
|
if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
|
||||||
|
userInfo := &UserInfo{}
|
||||||
|
|
||||||
|
// Extract standard JWT claims
|
||||||
|
if sub, ok := claims["sub"].(string); ok {
|
||||||
|
userInfo.ID = sub
|
||||||
|
}
|
||||||
|
if email, ok := claims["email"].(string); ok {
|
||||||
|
userInfo.Email = email
|
||||||
|
}
|
||||||
|
if name, ok := claims["name"].(string); ok {
|
||||||
|
userInfo.Name = name
|
||||||
|
}
|
||||||
|
if iss, ok := claims["iss"].(string); ok {
|
||||||
|
userInfo.Provider = iss
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to alternative claim names
|
||||||
|
if userInfo.ID == "" {
|
||||||
|
if userID, ok := claims["user_id"].(string); ok {
|
||||||
|
userInfo.ID = userID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to anonymous if no user ID found
|
||||||
|
if userInfo.ID == "" {
|
||||||
|
userInfo.ID = "anonymous"
|
||||||
|
}
|
||||||
|
|
||||||
|
return userInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid JWT claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMockJWT creates a mock JWT token for development/testing
|
||||||
|
func (a *AuthService) CreateMockJWT(userID, email, name string) (string, error) {
|
||||||
|
if !a.config.DevMode {
|
||||||
|
return "", fmt.Errorf("mock JWT creation only allowed in development mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the claims
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": userID,
|
||||||
|
"email": email,
|
||||||
|
"name": name,
|
||||||
|
"iss": "insertr-dev",
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|
||||||
|
// Sign and get the complete encoded token as a string
|
||||||
|
tokenString, err := token.SignedString([]byte(a.config.JWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign JWT: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenString, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates a JWT token without extracting user info
|
||||||
|
func (a *AuthService) ValidateToken(tokenString string) error {
|
||||||
|
_, err := a.parseJWT(tokenString)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken creates a new token with extended expiration
|
||||||
|
func (a *AuthService) RefreshToken(tokenString string) (string, error) {
|
||||||
|
userInfo, err := a.parseJWT(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot refresh invalid token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new token with same user info but extended expiration
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": userInfo.ID,
|
||||||
|
"email": userInfo.Email,
|
||||||
|
"name": userInfo.Name,
|
||||||
|
"iss": userInfo.Provider,
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"exp": time.Now().Add(24 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
return token.SignedString([]byte(a.config.JWTSecret))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAuthenticated checks if the request has valid authentication
|
||||||
|
func (a *AuthService) IsAuthenticated(r *http.Request) bool {
|
||||||
|
userInfo, err := a.ExtractUserFromRequest(r)
|
||||||
|
return err == nil && userInfo.ID != "anonymous"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAuth middleware that requires authentication
|
||||||
|
func (a *AuthService) RequireAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userInfo, err := a.ExtractUserFromRequest(r)
|
||||||
|
if err != nil || userInfo.ID == "anonymous" {
|
||||||
|
http.Error(w, "Authentication required", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user info to request context for use by handlers
|
||||||
|
ctx := r.Context()
|
||||||
|
ctx = ContextWithUser(ctx, userInfo)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth flow handlers (to be implemented when adding real OAuth)
|
||||||
|
|
||||||
|
// HandleOAuthLogin initiates OAuth flow
|
||||||
|
func (a *AuthService) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
provider := r.URL.Query().Get("provider")
|
||||||
|
if provider == "" {
|
||||||
|
provider = "google"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement OAuth initiation
|
||||||
|
// For now, return mock success in dev mode
|
||||||
|
if a.config.DevMode {
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"message": "OAuth login not yet implemented",
|
||||||
|
"redirect_url": "/auth/callback?code=mock_code&state=mock_state",
|
||||||
|
"dev_mode": true,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "OAuth not implemented", http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleOAuthCallback handles OAuth provider callback
|
||||||
|
func (a *AuthService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
_ = r.URL.Query().Get("state") // TODO: validate state parameter
|
||||||
|
|
||||||
|
// TODO: Implement OAuth token exchange
|
||||||
|
// For now, return mock token in dev mode
|
||||||
|
if a.config.DevMode && code != "" {
|
||||||
|
mockToken, err := a.CreateMockJWT("dev-user", "dev@localhost", "Development User")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to create mock token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"token": mockToken,
|
||||||
|
"dev_mode": true,
|
||||||
|
"message": "Mock OAuth callback successful",
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "OAuth callback not implemented", http.StatusNotImplemented)
|
||||||
|
}
|
||||||
29
internal/auth/context.go
Normal file
29
internal/auth/context.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Context keys for user information
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
userInfoContextKey contextKey = "user_info"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContextWithUser adds user information to request context
|
||||||
|
func ContextWithUser(ctx context.Context, user *UserInfo) context.Context {
|
||||||
|
return context.WithValue(ctx, userInfoContextKey, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserFromContext extracts user information from request context
|
||||||
|
func UserFromContext(ctx context.Context) (*UserInfo, bool) {
|
||||||
|
user, ok := ctx.Value(userInfoContextKey).(*UserInfo)
|
||||||
|
return user, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIDFromContext extracts user ID from request context
|
||||||
|
func UserIDFromContext(ctx context.Context) string {
|
||||||
|
if user, ok := UserFromContext(ctx); ok && user != nil {
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
return "anonymous"
|
||||||
|
}
|
||||||
@@ -212,3 +212,42 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
|
|||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upsertContent = `-- name: UpsertContent :one
|
||||||
|
INSERT INTO content (id, site_id, value, type, last_edited_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT(id, site_id) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
type = EXCLUDED.type,
|
||||||
|
last_edited_by = EXCLUDED.last_edited_by
|
||||||
|
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertContentParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
LastEditedBy string `json:"last_edited_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertContent,
|
||||||
|
arg.ID,
|
||||||
|
arg.SiteID,
|
||||||
|
arg.Value,
|
||||||
|
arg.Type,
|
||||||
|
arg.LastEditedBy,
|
||||||
|
)
|
||||||
|
var i Content
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Value,
|
||||||
|
&i.Type,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.LastEditedBy,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type Querier interface {
|
|||||||
InitializeSchema(ctx context.Context) error
|
InitializeSchema(ctx context.Context) error
|
||||||
InitializeVersionsTable(ctx context.Context) error
|
InitializeVersionsTable(ctx context.Context) error
|
||||||
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
|
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
|
||||||
|
UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
|
|||||||
@@ -212,3 +212,42 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
|
|||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upsertContent = `-- name: UpsertContent :one
|
||||||
|
INSERT INTO content (id, site_id, value, type, last_edited_by)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||||
|
ON CONFLICT(id, site_id) DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
type = EXCLUDED.type,
|
||||||
|
last_edited_by = EXCLUDED.last_edited_by
|
||||||
|
RETURNING id, site_id, value, type, created_at, updated_at, last_edited_by
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertContentParams struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SiteID string `json:"site_id"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
LastEditedBy string `json:"last_edited_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, upsertContent,
|
||||||
|
arg.ID,
|
||||||
|
arg.SiteID,
|
||||||
|
arg.Value,
|
||||||
|
arg.Type,
|
||||||
|
arg.LastEditedBy,
|
||||||
|
)
|
||||||
|
var i Content
|
||||||
|
err := row.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.SiteID,
|
||||||
|
&i.Value,
|
||||||
|
&i.Type,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.LastEditedBy,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ type Querier interface {
|
|||||||
InitializeSchema(ctx context.Context) error
|
InitializeSchema(ctx context.Context) error
|
||||||
InitializeVersionsTable(ctx context.Context) error
|
InitializeVersionsTable(ctx context.Context) error
|
||||||
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
|
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
|
||||||
|
UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
|
|||||||
2375
lib/package-lock.json
generated
2375
lib/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ export class ApiClient {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-User-ID': this.getCurrentUser()
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ value: content })
|
body: JSON.stringify({ value: content })
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ export class ApiClient {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-User-ID': this.getCurrentUser()
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
id: contentId,
|
id: contentId,
|
||||||
@@ -113,7 +113,7 @@ export class ApiClient {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'X-User-ID': this.getCurrentUser()
|
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
version_id: versionId
|
version_id: versionId
|
||||||
@@ -133,9 +133,171 @@ export class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get current user (for user attribution)
|
/**
|
||||||
|
* Get authentication token for API requests
|
||||||
|
* @returns {string} JWT token or mock token for development
|
||||||
|
*/
|
||||||
|
getAuthToken() {
|
||||||
|
// Check if we have a real JWT token from OAuth
|
||||||
|
const realToken = this.getStoredToken();
|
||||||
|
if (realToken && !this.isTokenExpired(realToken)) {
|
||||||
|
return realToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development/mock token for when no real auth is present
|
||||||
|
return this.getMockToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user information from token
|
||||||
|
* @returns {string} User identifier
|
||||||
|
*/
|
||||||
getCurrentUser() {
|
getCurrentUser() {
|
||||||
// This could be enhanced to get from authentication system
|
const token = this.getAuthToken();
|
||||||
|
|
||||||
|
// If it's a mock token, return mock user
|
||||||
|
if (token.startsWith('mock-')) {
|
||||||
|
return 'anonymous';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse real JWT token for user info
|
||||||
|
try {
|
||||||
|
const payload = this.parseJWT(token);
|
||||||
|
return payload.sub || payload.user_id || payload.email || 'anonymous';
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to parse JWT token:', error);
|
||||||
return 'anonymous';
|
return 'anonymous';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get stored JWT token from localStorage/sessionStorage
|
||||||
|
* @returns {string|null} Stored JWT token
|
||||||
|
*/
|
||||||
|
getStoredToken() {
|
||||||
|
// Try localStorage first (persistent), then sessionStorage (session-only)
|
||||||
|
return localStorage.getItem('insertr_auth_token') ||
|
||||||
|
sessionStorage.getItem('insertr_auth_token') ||
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store JWT token for future requests
|
||||||
|
* @param {string} token - JWT token from OAuth provider
|
||||||
|
* @param {boolean} persistent - Whether to use localStorage (true) or sessionStorage (false)
|
||||||
|
*/
|
||||||
|
setStoredToken(token, persistent = true) {
|
||||||
|
const storage = persistent ? localStorage : sessionStorage;
|
||||||
|
storage.setItem('insertr_auth_token', token);
|
||||||
|
|
||||||
|
// Clear the other storage to avoid conflicts
|
||||||
|
const otherStorage = persistent ? sessionStorage : localStorage;
|
||||||
|
otherStorage.removeItem('insertr_auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear stored authentication token
|
||||||
|
*/
|
||||||
|
clearStoredToken() {
|
||||||
|
localStorage.removeItem('insertr_auth_token');
|
||||||
|
sessionStorage.removeItem('insertr_auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate mock JWT token for development/testing
|
||||||
|
* @returns {string} Mock JWT token
|
||||||
|
*/
|
||||||
|
getMockToken() {
|
||||||
|
// Create a mock JWT-like token for development
|
||||||
|
// Format: mock-{user}-{timestamp}-{random}
|
||||||
|
const user = 'anonymous';
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const random = Math.random().toString(36).substr(2, 9);
|
||||||
|
return `mock-${user}-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse JWT token payload
|
||||||
|
* @param {string} token - JWT token
|
||||||
|
* @returns {object} Parsed payload
|
||||||
|
*/
|
||||||
|
parseJWT(token) {
|
||||||
|
if (token.startsWith('mock-')) {
|
||||||
|
// Return mock payload for development tokens
|
||||||
|
return {
|
||||||
|
sub: 'anonymous',
|
||||||
|
user_id: 'anonymous',
|
||||||
|
email: 'anonymous@localhost',
|
||||||
|
iss: 'insertr-dev',
|
||||||
|
exp: Date.now() + 24 * 60 * 60 * 1000 // 24 hours from now
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse real JWT token
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error('Invalid JWT format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||||
|
return payload;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to parse JWT token: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if JWT token is expired
|
||||||
|
* @param {string} token - JWT token
|
||||||
|
* @returns {boolean} True if token is expired
|
||||||
|
*/
|
||||||
|
isTokenExpired(token) {
|
||||||
|
try {
|
||||||
|
const payload = this.parseJWT(token);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return payload.exp && payload.exp < now;
|
||||||
|
} catch (error) {
|
||||||
|
// If we can't parse the token, consider it expired
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize OAuth flow with provider (Google, GitHub, etc.)
|
||||||
|
* @param {string} provider - OAuth provider ('google', 'github', etc.)
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async initiateOAuth(provider = 'google') {
|
||||||
|
// This will be implemented when we add real OAuth integration
|
||||||
|
console.log(`🔐 OAuth flow with ${provider} not yet implemented`);
|
||||||
|
console.log('💡 For now, using mock authentication in development');
|
||||||
|
|
||||||
|
// Store a mock token for development
|
||||||
|
const mockToken = this.getMockToken();
|
||||||
|
this.setStoredToken(mockToken, true);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle OAuth callback after user returns from provider
|
||||||
|
* @param {URLSearchParams} urlParams - URL parameters from OAuth callback
|
||||||
|
* @returns {Promise<boolean>} Success status
|
||||||
|
*/
|
||||||
|
async handleOAuthCallback(urlParams) {
|
||||||
|
// This will be implemented when we add real OAuth integration
|
||||||
|
const code = urlParams.get('code');
|
||||||
|
const state = urlParams.get('state');
|
||||||
|
|
||||||
|
if (code) {
|
||||||
|
console.log('🔐 OAuth callback received, exchanging code for token...');
|
||||||
|
// TODO: Exchange authorization code for JWT token
|
||||||
|
// const token = await this.exchangeCodeForToken(code, state);
|
||||||
|
// this.setStoredToken(token, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,7 @@ export class InsertrEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update element content regardless of API success (optimistic update)
|
|
||||||
this.updateElementContent(meta.element, formData);
|
|
||||||
|
|
||||||
// Close form
|
// Close form
|
||||||
this.formRenderer.closeForm();
|
this.formRenderer.closeForm();
|
||||||
@@ -127,8 +126,7 @@ export class InsertrEditor {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error saving content:', error);
|
console.error('❌ Error saving content:', error);
|
||||||
|
|
||||||
// Still update the UI even if API fails
|
|
||||||
this.updateElementContent(meta.element, formData);
|
|
||||||
this.formRenderer.closeForm();
|
this.formRenderer.closeForm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,44 +138,15 @@ export class InsertrEditor {
|
|||||||
return 'link';
|
return 'link';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagName === 'p' || tagName === 'div') {
|
// ALL text elements use markdown for consistent editing experience
|
||||||
return 'markdown';
|
return 'markdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to text for headings and other elements
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleCancel(meta) {
|
handleCancel(meta) {
|
||||||
console.log('❌ Edit cancelled:', meta.contentId);
|
console.log('❌ Edit cancelled:', meta.contentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateElementContent(element, formData) {
|
|
||||||
// Skip updating markdown elements and groups - they're handled by the unified markdown editor
|
|
||||||
if (element.classList.contains('insertr-group') || this.isMarkdownElement(element)) {
|
|
||||||
console.log('🔄 Skipping element update - handled by unified markdown editor');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (element.tagName.toLowerCase() === 'a') {
|
|
||||||
// Update link element
|
|
||||||
if (formData.text !== undefined) {
|
|
||||||
element.textContent = formData.text;
|
|
||||||
}
|
|
||||||
if (formData.url !== undefined) {
|
|
||||||
element.setAttribute('href', formData.url);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Update text content for non-markdown elements
|
|
||||||
element.textContent = formData.text || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isMarkdownElement(element) {
|
|
||||||
// Check if element uses markdown based on form config
|
|
||||||
const markdownTags = new Set(['p', 'h3', 'h4', 'h5', 'h6', 'span']);
|
|
||||||
return markdownTags.has(element.tagName.toLowerCase());
|
|
||||||
}
|
|
||||||
addEditorStyles() {
|
addEditorStyles() {
|
||||||
const styles = `
|
const styles = `
|
||||||
.insertr-editing-hover {
|
.insertr-editing-hover {
|
||||||
|
|||||||
492
lib/src/ui/Editor.js
Normal file
492
lib/src/ui/Editor.js
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
/**
|
||||||
|
* Editor - Handles all content types with markdown-first approach
|
||||||
|
*/
|
||||||
|
import { markdownConverter } from '../utils/markdown.js';
|
||||||
|
import { Previewer } from './Previewer.js';
|
||||||
|
|
||||||
|
export class Editor {
|
||||||
|
constructor() {
|
||||||
|
this.currentOverlay = null;
|
||||||
|
this.previewer = new Previewer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit any content element with markdown interface
|
||||||
|
* @param {Object} meta - Element metadata {element, contentId, contentType}
|
||||||
|
* @param {string|Object} currentContent - Current content value
|
||||||
|
* @param {Function} onSave - Save callback
|
||||||
|
* @param {Function} onCancel - Cancel callback
|
||||||
|
*/
|
||||||
|
edit(meta, currentContent, onSave, onCancel) {
|
||||||
|
const { element } = meta;
|
||||||
|
|
||||||
|
// Handle both single elements and groups uniformly
|
||||||
|
const elements = Array.isArray(element) ? element : [element];
|
||||||
|
const context = new EditContext(elements, currentContent);
|
||||||
|
|
||||||
|
// Close any existing editor
|
||||||
|
this.close();
|
||||||
|
|
||||||
|
// Create editor form
|
||||||
|
const form = this.createForm(context, meta);
|
||||||
|
const overlay = this.createOverlay(form);
|
||||||
|
|
||||||
|
// Position relative to primary element
|
||||||
|
this.positionForm(context.primaryElement, overlay);
|
||||||
|
|
||||||
|
// Setup event handlers
|
||||||
|
this.setupEventHandlers(form, overlay, context, { onSave, onCancel });
|
||||||
|
|
||||||
|
// Show editor
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
this.currentOverlay = overlay;
|
||||||
|
|
||||||
|
// Focus textarea
|
||||||
|
const textarea = form.querySelector('textarea');
|
||||||
|
if (textarea) {
|
||||||
|
setTimeout(() => textarea.focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create editing form for any content type
|
||||||
|
*/
|
||||||
|
createForm(context, meta) {
|
||||||
|
const config = this.getFieldConfig(context);
|
||||||
|
const currentContent = context.extractContent();
|
||||||
|
|
||||||
|
const form = document.createElement('div');
|
||||||
|
form.className = 'insertr-edit-form';
|
||||||
|
|
||||||
|
// Build form HTML
|
||||||
|
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
|
||||||
|
|
||||||
|
// Markdown textarea (always present)
|
||||||
|
formHTML += this.createMarkdownField(config, currentContent);
|
||||||
|
|
||||||
|
// URL field (for links only)
|
||||||
|
if (config.includeUrl) {
|
||||||
|
formHTML += this.createUrlField(currentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form actions
|
||||||
|
formHTML += `
|
||||||
|
<div class="insertr-form-actions">
|
||||||
|
<button type="button" class="insertr-btn-save">Save</button>
|
||||||
|
<button type="button" class="insertr-btn-cancel">Cancel</button>
|
||||||
|
<button type="button" class="insertr-btn-history" data-content-id="${meta.contentId}">View History</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
form.innerHTML = formHTML;
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get field configuration for any element type (markdown-first)
|
||||||
|
*/
|
||||||
|
getFieldConfig(context) {
|
||||||
|
const elementCount = context.elements.length;
|
||||||
|
const primaryElement = context.primaryElement;
|
||||||
|
const isLink = primaryElement.tagName.toLowerCase() === 'a';
|
||||||
|
|
||||||
|
// Multi-element groups
|
||||||
|
if (elementCount > 1) {
|
||||||
|
return {
|
||||||
|
type: 'markdown',
|
||||||
|
includeUrl: false,
|
||||||
|
label: `Group Content (${elementCount} elements)`,
|
||||||
|
rows: Math.max(8, elementCount * 2),
|
||||||
|
placeholder: 'Edit all content together using markdown...'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single elements - all get markdown by default
|
||||||
|
const tag = primaryElement.tagName.toLowerCase();
|
||||||
|
const baseConfig = {
|
||||||
|
type: 'markdown',
|
||||||
|
includeUrl: isLink,
|
||||||
|
placeholder: 'Enter content using markdown...'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Customize by element type
|
||||||
|
switch (tag) {
|
||||||
|
case 'h1':
|
||||||
|
return { ...baseConfig, label: 'Main Headline', rows: 1, placeholder: 'Enter main headline...' };
|
||||||
|
case 'h2':
|
||||||
|
return { ...baseConfig, label: 'Subheading', rows: 1, placeholder: 'Enter subheading...' };
|
||||||
|
case 'h3': case 'h4': case 'h5': case 'h6':
|
||||||
|
return { ...baseConfig, label: 'Heading', rows: 2, placeholder: 'Enter heading (markdown supported)...' };
|
||||||
|
case 'p':
|
||||||
|
return { ...baseConfig, label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' };
|
||||||
|
case 'span':
|
||||||
|
return { ...baseConfig, label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' };
|
||||||
|
case 'button':
|
||||||
|
return { ...baseConfig, label: 'Button Text', rows: 1, placeholder: 'Enter button text...' };
|
||||||
|
case 'a':
|
||||||
|
return { ...baseConfig, label: 'Link', rows: 2, placeholder: 'Enter link text (markdown supported)...' };
|
||||||
|
default:
|
||||||
|
return { ...baseConfig, label: 'Content', rows: 3, placeholder: 'Enter content using markdown...' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create markdown textarea field
|
||||||
|
*/
|
||||||
|
createMarkdownField(config, content) {
|
||||||
|
const textContent = typeof content === 'object' ? content.text || '' : content;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="insertr-form-group">
|
||||||
|
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
|
||||||
|
rows="${config.rows}"
|
||||||
|
placeholder="${config.placeholder}">${this.escapeHtml(textContent)}</textarea>
|
||||||
|
<div class="insertr-form-help">
|
||||||
|
Supports Markdown formatting (bold, italic, links, etc.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create URL field for links
|
||||||
|
*/
|
||||||
|
createUrlField(content) {
|
||||||
|
const url = typeof content === 'object' ? content.url || '' : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="insertr-form-group">
|
||||||
|
<label class="insertr-form-label">Link URL:</label>
|
||||||
|
<input type="url" class="insertr-form-input" name="url"
|
||||||
|
value="${this.escapeHtml(url)}"
|
||||||
|
placeholder="https://example.com">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup event handlers
|
||||||
|
*/
|
||||||
|
setupEventHandlers(form, overlay, context, { onSave, onCancel }) {
|
||||||
|
const textarea = form.querySelector('textarea');
|
||||||
|
const urlInput = form.querySelector('input[name="url"]');
|
||||||
|
const saveBtn = form.querySelector('.insertr-btn-save');
|
||||||
|
const cancelBtn = form.querySelector('.insertr-btn-cancel');
|
||||||
|
const historyBtn = form.querySelector('.insertr-btn-history');
|
||||||
|
|
||||||
|
// Initialize previewer
|
||||||
|
this.previewer.setActiveContext(context);
|
||||||
|
|
||||||
|
// Setup live preview for content changes
|
||||||
|
if (textarea) {
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
const content = this.extractFormData(form);
|
||||||
|
this.previewer.schedulePreview(context, content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup live preview for URL changes (links only)
|
||||||
|
if (urlInput) {
|
||||||
|
urlInput.addEventListener('input', () => {
|
||||||
|
const content = this.extractFormData(form);
|
||||||
|
this.previewer.schedulePreview(context, content);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save handler
|
||||||
|
if (saveBtn) {
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const content = this.extractFormData(form);
|
||||||
|
|
||||||
|
// Apply final content to elements
|
||||||
|
context.applyContent(content);
|
||||||
|
|
||||||
|
// Update stored original content to match current state
|
||||||
|
// This makes the saved content the new baseline for future edits
|
||||||
|
context.updateOriginalContent();
|
||||||
|
|
||||||
|
// Clear preview styling (won't restore content since original matches current)
|
||||||
|
this.previewer.clearPreview();
|
||||||
|
|
||||||
|
// Callback with the content
|
||||||
|
onSave(content);
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel handler
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
this.previewer.clearPreview();
|
||||||
|
onCancel();
|
||||||
|
this.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// History handler
|
||||||
|
if (historyBtn) {
|
||||||
|
historyBtn.addEventListener('click', () => {
|
||||||
|
const contentId = historyBtn.getAttribute('data-content-id');
|
||||||
|
console.log('Version history not implemented yet for:', contentId);
|
||||||
|
// TODO: Implement version history integration
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC key handler
|
||||||
|
const keyHandler = (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.previewer.clearPreview();
|
||||||
|
onCancel();
|
||||||
|
this.close();
|
||||||
|
document.removeEventListener('keydown', keyHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', keyHandler);
|
||||||
|
|
||||||
|
// Click outside handler
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
this.previewer.clearPreview();
|
||||||
|
onCancel();
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract form data consistently
|
||||||
|
*/
|
||||||
|
extractFormData(form) {
|
||||||
|
const textarea = form.querySelector('textarea[name="content"]');
|
||||||
|
const urlInput = form.querySelector('input[name="url"]');
|
||||||
|
|
||||||
|
const content = textarea ? textarea.value : '';
|
||||||
|
|
||||||
|
if (urlInput) {
|
||||||
|
// Link content
|
||||||
|
return {
|
||||||
|
text: content,
|
||||||
|
url: urlInput.value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular content
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create overlay with backdrop
|
||||||
|
*/
|
||||||
|
createOverlay(form) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'insertr-form-overlay';
|
||||||
|
overlay.appendChild(form);
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Position form relative to primary element
|
||||||
|
*/
|
||||||
|
positionForm(element, overlay) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const form = overlay.querySelector('.insertr-edit-form');
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
|
||||||
|
// Calculate optimal width
|
||||||
|
let formWidth;
|
||||||
|
if (viewportWidth < 768) {
|
||||||
|
formWidth = Math.min(viewportWidth - 40, 500);
|
||||||
|
} else {
|
||||||
|
const minComfortableWidth = 600;
|
||||||
|
const maxWidth = Math.min(viewportWidth * 0.9, 800);
|
||||||
|
formWidth = Math.max(minComfortableWidth, Math.min(rect.width * 1.5, maxWidth));
|
||||||
|
}
|
||||||
|
|
||||||
|
form.style.width = `${formWidth}px`;
|
||||||
|
|
||||||
|
// Position below element
|
||||||
|
const top = rect.bottom + window.scrollY + 10;
|
||||||
|
const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
|
||||||
|
const minLeft = 20;
|
||||||
|
const maxLeft = window.innerWidth - formWidth - 20;
|
||||||
|
const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
|
||||||
|
|
||||||
|
overlay.style.position = 'absolute';
|
||||||
|
overlay.style.top = `${top}px`;
|
||||||
|
overlay.style.left = `${left}px`;
|
||||||
|
overlay.style.zIndex = '10000';
|
||||||
|
|
||||||
|
// Ensure visibility
|
||||||
|
this.ensureModalVisible(overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure modal is visible by scrolling if needed
|
||||||
|
*/
|
||||||
|
ensureModalVisible(overlay) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const modal = overlay.querySelector('.insertr-edit-form');
|
||||||
|
const modalRect = modal.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
if (modalRect.bottom > viewportHeight) {
|
||||||
|
const scrollAmount = modalRect.bottom - viewportHeight + 20;
|
||||||
|
window.scrollBy({
|
||||||
|
top: scrollAmount,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close current editor
|
||||||
|
*/
|
||||||
|
close() {
|
||||||
|
if (this.previewer) {
|
||||||
|
this.previewer.clearPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.currentOverlay) {
|
||||||
|
this.currentOverlay.remove();
|
||||||
|
this.currentOverlay = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
*/
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (typeof text !== 'string') return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EditContext - Represents content elements for editing
|
||||||
|
*/
|
||||||
|
class EditContext {
|
||||||
|
constructor(elements, currentContent) {
|
||||||
|
this.elements = elements;
|
||||||
|
this.primaryElement = elements[0];
|
||||||
|
this.originalContent = null;
|
||||||
|
this.currentContent = currentContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract content from elements in markdown format
|
||||||
|
*/
|
||||||
|
extractContent() {
|
||||||
|
if (this.elements.length === 1) {
|
||||||
|
const element = this.elements[0];
|
||||||
|
|
||||||
|
// Handle links specially
|
||||||
|
if (element.tagName.toLowerCase() === 'a') {
|
||||||
|
return {
|
||||||
|
text: markdownConverter.htmlToMarkdown(element.innerHTML),
|
||||||
|
url: element.href
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element - convert to markdown
|
||||||
|
return markdownConverter.htmlToMarkdown(element.innerHTML);
|
||||||
|
} else {
|
||||||
|
// Multiple elements - use group extraction
|
||||||
|
return markdownConverter.extractGroupMarkdown(this.elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply content to elements from markdown/object
|
||||||
|
*/
|
||||||
|
applyContent(content) {
|
||||||
|
if (this.elements.length === 1) {
|
||||||
|
const element = this.elements[0];
|
||||||
|
|
||||||
|
// Handle links specially
|
||||||
|
if (element.tagName.toLowerCase() === 'a' && typeof content === 'object') {
|
||||||
|
element.innerHTML = markdownConverter.markdownToHtml(content.text || '');
|
||||||
|
if (content.url) {
|
||||||
|
element.href = content.url;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single element - convert markdown to HTML
|
||||||
|
const html = markdownConverter.markdownToHtml(content);
|
||||||
|
element.innerHTML = html;
|
||||||
|
} else {
|
||||||
|
// Multiple elements - use group update
|
||||||
|
markdownConverter.updateGroupElements(this.elements, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store original content for preview restoration
|
||||||
|
*/
|
||||||
|
storeOriginalContent() {
|
||||||
|
this.originalContent = this.elements.map(el => ({
|
||||||
|
innerHTML: el.innerHTML,
|
||||||
|
href: el.href // Store href for links
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore original content (for preview cancellation)
|
||||||
|
*/
|
||||||
|
restoreOriginalContent() {
|
||||||
|
if (this.originalContent) {
|
||||||
|
this.elements.forEach((el, index) => {
|
||||||
|
if (this.originalContent[index] !== undefined) {
|
||||||
|
el.innerHTML = this.originalContent[index].innerHTML;
|
||||||
|
if (this.originalContent[index].href) {
|
||||||
|
el.href = this.originalContent[index].href;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update original content to match current element state (after save)
|
||||||
|
* This makes the current content the new baseline for future cancellations
|
||||||
|
*/
|
||||||
|
updateOriginalContent() {
|
||||||
|
this.originalContent = this.elements.map(el => ({
|
||||||
|
innerHTML: el.innerHTML,
|
||||||
|
href: el.href // Store href for links
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply preview styling to all elements
|
||||||
|
*/
|
||||||
|
applyPreviewStyling() {
|
||||||
|
this.elements.forEach(el => {
|
||||||
|
el.classList.add('insertr-preview-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also apply to containers if they're groups
|
||||||
|
if (this.primaryElement.classList.contains('insertr-group')) {
|
||||||
|
this.primaryElement.classList.add('insertr-preview-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove preview styling from all elements
|
||||||
|
*/
|
||||||
|
removePreviewStyling() {
|
||||||
|
this.elements.forEach(el => {
|
||||||
|
el.classList.remove('insertr-preview-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also remove from containers
|
||||||
|
if (this.primaryElement.classList.contains('insertr-group')) {
|
||||||
|
this.primaryElement.classList.remove('insertr-preview-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
lib/src/ui/Previewer.js
Normal file
157
lib/src/ui/Previewer.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Previewer - Handles live preview for all content types
|
||||||
|
*/
|
||||||
|
import { markdownConverter } from '../utils/markdown.js';
|
||||||
|
|
||||||
|
export class Previewer {
|
||||||
|
constructor() {
|
||||||
|
this.previewTimeout = null;
|
||||||
|
this.activeContext = null;
|
||||||
|
this.resizeObserver = null;
|
||||||
|
this.onHeightChange = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the active editing context for preview
|
||||||
|
*/
|
||||||
|
setActiveContext(context) {
|
||||||
|
this.clearPreview();
|
||||||
|
this.activeContext = context;
|
||||||
|
this.startResizeObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule a preview update with debouncing
|
||||||
|
*/
|
||||||
|
schedulePreview(context, content) {
|
||||||
|
// Clear existing timeout
|
||||||
|
if (this.previewTimeout) {
|
||||||
|
clearTimeout(this.previewTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule new preview with 500ms debounce
|
||||||
|
this.previewTimeout = setTimeout(() => {
|
||||||
|
this.updatePreview(context, content);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update preview with new content
|
||||||
|
*/
|
||||||
|
updatePreview(context, content) {
|
||||||
|
// Store original content if first preview
|
||||||
|
if (!context.originalContent) {
|
||||||
|
context.storeOriginalContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply preview content to elements
|
||||||
|
this.applyPreviewContent(context, content);
|
||||||
|
context.applyPreviewStyling();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply preview content to context elements
|
||||||
|
*/
|
||||||
|
applyPreviewContent(context, content) {
|
||||||
|
if (context.elements.length === 1) {
|
||||||
|
const element = context.elements[0];
|
||||||
|
|
||||||
|
// Handle links specially
|
||||||
|
if (element.tagName.toLowerCase() === 'a') {
|
||||||
|
if (typeof content === 'object') {
|
||||||
|
// Update link text (markdown to HTML)
|
||||||
|
if (content.text !== undefined) {
|
||||||
|
const html = markdownConverter.markdownToHtml(content.text);
|
||||||
|
element.innerHTML = html;
|
||||||
|
}
|
||||||
|
// Update link URL
|
||||||
|
if (content.url !== undefined && content.url.trim()) {
|
||||||
|
element.href = content.url;
|
||||||
|
}
|
||||||
|
} else if (content && content.trim()) {
|
||||||
|
// Just markdown content for link text
|
||||||
|
const html = markdownConverter.markdownToHtml(content);
|
||||||
|
element.innerHTML = html;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular single element
|
||||||
|
if (content && content.trim()) {
|
||||||
|
const html = markdownConverter.markdownToHtml(content);
|
||||||
|
element.innerHTML = html;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Multiple elements - use group update
|
||||||
|
if (content && content.trim()) {
|
||||||
|
markdownConverter.updateGroupElements(context.elements, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all preview state and restore original content
|
||||||
|
*/
|
||||||
|
clearPreview() {
|
||||||
|
if (this.activeContext) {
|
||||||
|
this.activeContext.restoreOriginalContent();
|
||||||
|
this.activeContext.removePreviewStyling();
|
||||||
|
this.activeContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.previewTimeout) {
|
||||||
|
clearTimeout(this.previewTimeout);
|
||||||
|
this.previewTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stopResizeObserver();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start observing element size changes for modal repositioning
|
||||||
|
*/
|
||||||
|
startResizeObserver() {
|
||||||
|
this.stopResizeObserver();
|
||||||
|
|
||||||
|
if (this.activeContext) {
|
||||||
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
|
// Handle height changes for modal repositioning
|
||||||
|
if (this.onHeightChange) {
|
||||||
|
this.onHeightChange(this.activeContext.primaryElement);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Observe all elements in the context
|
||||||
|
this.activeContext.elements.forEach(el => {
|
||||||
|
this.resizeObserver.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop observing element size changes
|
||||||
|
*/
|
||||||
|
stopResizeObserver() {
|
||||||
|
if (this.resizeObserver) {
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
|
this.resizeObserver = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set callback for height changes (for modal repositioning)
|
||||||
|
*/
|
||||||
|
setHeightChangeCallback(callback) {
|
||||||
|
this.onHeightChange = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique element ID for tracking
|
||||||
|
*/
|
||||||
|
getElementId(element) {
|
||||||
|
if (!element._insertrId) {
|
||||||
|
element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
return element._insertrId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,270 +1,35 @@
|
|||||||
import { markdownConverter } from '../utils/markdown.js';
|
|
||||||
import { MarkdownEditor } from './markdown-editor.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LivePreviewManager - Handles debounced live preview updates for non-markdown elements
|
* InsertrFormRenderer - Form renderer using markdown-first approach
|
||||||
|
* Thin wrapper around the Editor system
|
||||||
*/
|
*/
|
||||||
class LivePreviewManager {
|
import { Editor } from './Editor.js';
|
||||||
constructor() {
|
|
||||||
this.previewTimeouts = new Map();
|
|
||||||
this.activeElement = null;
|
|
||||||
this.originalContent = null;
|
|
||||||
this.originalStyles = null;
|
|
||||||
this.resizeObserver = null;
|
|
||||||
this.onHeightChangeCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
schedulePreview(element, newValue, elementType) {
|
|
||||||
const elementId = this.getElementId(element);
|
|
||||||
|
|
||||||
// Clear existing timeout
|
|
||||||
if (this.previewTimeouts.has(elementId)) {
|
|
||||||
clearTimeout(this.previewTimeouts.get(elementId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule new preview update with 500ms debounce
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
this.updatePreview(element, newValue, elementType);
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
this.previewTimeouts.set(elementId, timeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
updatePreview(element, newValue, elementType) {
|
|
||||||
// Store original content if first preview
|
|
||||||
if (!this.originalContent && this.activeElement === element) {
|
|
||||||
this.originalContent = this.extractOriginalContent(element, elementType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply preview styling and content
|
|
||||||
this.applyPreviewContent(element, newValue, elementType);
|
|
||||||
|
|
||||||
// ResizeObserver will automatically detect height changes
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
extractOriginalContent(element, elementType) {
|
|
||||||
switch (elementType) {
|
|
||||||
case 'link':
|
|
||||||
return {
|
|
||||||
text: element.textContent,
|
|
||||||
url: element.href
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return element.textContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyPreviewContent(element, newValue, elementType) {
|
|
||||||
// Add preview indicator
|
|
||||||
element.classList.add('insertr-preview-active');
|
|
||||||
|
|
||||||
// Update content based on element type
|
|
||||||
switch (elementType) {
|
|
||||||
case 'text':
|
|
||||||
case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
|
|
||||||
case 'span': case 'button':
|
|
||||||
if (newValue && newValue.trim()) {
|
|
||||||
element.textContent = newValue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'textarea':
|
|
||||||
case 'p':
|
|
||||||
if (newValue && newValue.trim()) {
|
|
||||||
element.textContent = newValue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'link':
|
|
||||||
if (typeof newValue === 'object') {
|
|
||||||
if (newValue.text !== undefined && newValue.text.trim()) {
|
|
||||||
element.textContent = newValue.text;
|
|
||||||
}
|
|
||||||
if (newValue.url !== undefined && newValue.url.trim()) {
|
|
||||||
element.href = newValue.url;
|
|
||||||
}
|
|
||||||
} else if (newValue && newValue.trim()) {
|
|
||||||
element.textContent = newValue;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPreview(element) {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const elementId = this.getElementId(element);
|
|
||||||
|
|
||||||
// Clear any pending preview
|
|
||||||
if (this.previewTimeouts.has(elementId)) {
|
|
||||||
clearTimeout(this.previewTimeouts.get(elementId));
|
|
||||||
this.previewTimeouts.delete(elementId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop ResizeObserver
|
|
||||||
this.stopResizeObserver();
|
|
||||||
|
|
||||||
// Restore original content
|
|
||||||
if (this.originalContent && element === this.activeElement) {
|
|
||||||
this.restoreOriginalContent(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove preview styling
|
|
||||||
element.classList.remove('insertr-preview-active');
|
|
||||||
|
|
||||||
this.activeElement = null;
|
|
||||||
this.originalContent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
restoreOriginalContent(element) {
|
|
||||||
if (!this.originalContent) return;
|
|
||||||
|
|
||||||
if (typeof this.originalContent === 'object') {
|
|
||||||
// Link element
|
|
||||||
element.textContent = this.originalContent.text;
|
|
||||||
if (this.originalContent.url) {
|
|
||||||
element.href = this.originalContent.url;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Text element
|
|
||||||
element.textContent = this.originalContent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getElementId(element) {
|
|
||||||
// Create unique ID for element tracking
|
|
||||||
if (!element._insertrId) {
|
|
||||||
element._insertrId = 'insertr_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
||||||
}
|
|
||||||
return element._insertrId;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveElement(element) {
|
|
||||||
this.activeElement = element;
|
|
||||||
this.originalContent = null;
|
|
||||||
this.startResizeObserver(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHeightChangeCallback(callback) {
|
|
||||||
this.onHeightChangeCallback = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
startResizeObserver(element) {
|
|
||||||
// Clean up existing observer
|
|
||||||
this.stopResizeObserver();
|
|
||||||
|
|
||||||
// Create new ResizeObserver for this element
|
|
||||||
this.resizeObserver = new ResizeObserver(entries => {
|
|
||||||
// Use requestAnimationFrame to ensure smooth updates
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (this.onHeightChangeCallback && element === this.activeElement) {
|
|
||||||
this.onHeightChangeCallback(element);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start observing the element
|
|
||||||
this.resizeObserver.observe(element);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopResizeObserver() {
|
|
||||||
if (this.resizeObserver) {
|
|
||||||
this.resizeObserver.disconnect();
|
|
||||||
this.resizeObserver = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* InsertrFormRenderer - Professional modal editing forms with live preview
|
|
||||||
* Enhanced with debounced live preview and comfortable input sizing
|
|
||||||
*/
|
|
||||||
export class InsertrFormRenderer {
|
export class InsertrFormRenderer {
|
||||||
constructor(apiClient = null) {
|
constructor(apiClient = null) {
|
||||||
this.apiClient = apiClient;
|
this.apiClient = apiClient;
|
||||||
this.currentOverlay = null;
|
this.editor = new Editor();
|
||||||
this.previewManager = new LivePreviewManager();
|
|
||||||
this.markdownEditor = new MarkdownEditor();
|
|
||||||
this.setupStyles();
|
this.setupStyles();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create and show edit form for content element
|
* Show edit form for any content element
|
||||||
* @param {Object} meta - Element metadata {element, contentId, contentType}
|
* @param {Object} meta - Element metadata {element, contentId, contentType}
|
||||||
* @param {string} currentContent - Current content value
|
* @param {string|Object} currentContent - Current content value
|
||||||
* @param {Function} onSave - Save callback
|
* @param {Function} onSave - Save callback
|
||||||
* @param {Function} onCancel - Cancel callback
|
* @param {Function} onCancel - Cancel callback
|
||||||
*/
|
*/
|
||||||
showEditForm(meta, currentContent, onSave, onCancel) {
|
showEditForm(meta, currentContent, onSave, onCancel) {
|
||||||
// Close any existing form
|
const { element } = meta;
|
||||||
this.closeForm();
|
|
||||||
|
|
||||||
const { element, contentId, contentType } = meta;
|
// Handle insertr-group elements by getting their viable children
|
||||||
const config = this.getFieldConfig(element, contentType);
|
|
||||||
|
|
||||||
// Route to unified markdown editor for markdown content
|
|
||||||
if (config.type === 'markdown') {
|
|
||||||
return this.markdownEditor.edit(element, onSave, onCancel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route to unified markdown editor for group elements
|
|
||||||
if (element.classList.contains('insertr-group')) {
|
if (element.classList.contains('insertr-group')) {
|
||||||
const children = this.getGroupChildren(element);
|
const children = this.getGroupChildren(element);
|
||||||
return this.markdownEditor.edit(children, onSave, onCancel);
|
const groupMeta = { ...meta, element: children };
|
||||||
|
return this.editor.edit(groupMeta, currentContent, onSave, onCancel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle non-markdown elements (text, links, etc.) with legacy system
|
// All other elements use the editor directly
|
||||||
return this.showLegacyEditForm(meta, currentContent, onSave, onCancel);
|
return this.editor.edit(meta, currentContent, onSave, onCancel);
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show legacy edit form for non-markdown elements (text, links, etc.)
|
|
||||||
*/
|
|
||||||
showLegacyEditForm(meta, currentContent, onSave, onCancel) {
|
|
||||||
const { element, contentId, contentType } = meta;
|
|
||||||
const config = this.getFieldConfig(element, contentType);
|
|
||||||
|
|
||||||
// Initialize preview manager for this element
|
|
||||||
this.previewManager.setActiveElement(element);
|
|
||||||
|
|
||||||
// Set up height change callback
|
|
||||||
this.previewManager.setHeightChangeCallback((changedElement) => {
|
|
||||||
this.repositionModal(changedElement, overlay);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create form
|
|
||||||
const form = this.createEditForm(contentId, config, currentContent);
|
|
||||||
|
|
||||||
// Create overlay with backdrop
|
|
||||||
const overlay = this.createOverlay(form);
|
|
||||||
|
|
||||||
// Position form with enhanced sizing
|
|
||||||
this.positionForm(element, overlay);
|
|
||||||
|
|
||||||
// Setup event handlers with live preview
|
|
||||||
this.setupFormHandlers(form, overlay, element, config, { onSave, onCancel });
|
|
||||||
|
|
||||||
// Show form
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
this.currentOverlay = overlay;
|
|
||||||
|
|
||||||
// Focus first input
|
|
||||||
const firstInput = form.querySelector('input, textarea');
|
|
||||||
if (firstInput) {
|
|
||||||
setTimeout(() => firstInput.focus(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return overlay;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -273,7 +38,7 @@ export class InsertrFormRenderer {
|
|||||||
getGroupChildren(groupElement) {
|
getGroupChildren(groupElement) {
|
||||||
const children = [];
|
const children = [];
|
||||||
for (const child of groupElement.children) {
|
for (const child of groupElement.children) {
|
||||||
// Skip elements that don't have text content
|
// Skip elements that don't have meaningful text content
|
||||||
if (child.textContent.trim().length > 0) {
|
if (child.textContent.trim().length > 0) {
|
||||||
children.push(child);
|
children.push(child);
|
||||||
}
|
}
|
||||||
@@ -285,190 +50,28 @@ export class InsertrFormRenderer {
|
|||||||
* Close current form
|
* Close current form
|
||||||
*/
|
*/
|
||||||
closeForm() {
|
closeForm() {
|
||||||
// Close markdown editor if active
|
this.editor.close();
|
||||||
this.markdownEditor.close();
|
|
||||||
|
|
||||||
// Clear any active legacy previews
|
|
||||||
if (this.previewManager.activeElement) {
|
|
||||||
this.previewManager.clearPreview(this.previewManager.activeElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentOverlay) {
|
|
||||||
this.currentOverlay.remove();
|
|
||||||
this.currentOverlay = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate field configuration based on element
|
* Show version history modal (placeholder for future implementation)
|
||||||
*/
|
|
||||||
getFieldConfig(element, contentType) {
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
const classList = Array.from(element.classList);
|
|
||||||
|
|
||||||
// Default configurations based on element type - using markdown for rich content
|
|
||||||
const configs = {
|
|
||||||
h1: { type: 'text', label: 'Headline', maxLength: 60, placeholder: 'Enter headline...' },
|
|
||||||
h2: { type: 'text', label: 'Subheading', maxLength: 80, placeholder: 'Enter subheading...' },
|
|
||||||
h3: { type: 'markdown', label: 'Section Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
|
|
||||||
h4: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
|
|
||||||
h5: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
|
|
||||||
h6: { type: 'markdown', label: 'Title', rows: 2, placeholder: 'Enter title (markdown supported)...' },
|
|
||||||
p: { type: 'markdown', label: 'Content', rows: 4, placeholder: 'Enter content using markdown...' },
|
|
||||||
a: { type: 'link', label: 'Link', placeholder: 'Enter link text...', includeUrl: true },
|
|
||||||
span: { type: 'markdown', label: 'Text', rows: 2, placeholder: 'Enter text (markdown supported)...' },
|
|
||||||
button: { type: 'text', label: 'Button Text', placeholder: 'Enter button text...' },
|
|
||||||
};
|
|
||||||
|
|
||||||
let config = configs[tagName] || { type: 'text', label: 'Text', placeholder: 'Enter text...' };
|
|
||||||
|
|
||||||
// CSS class enhancements
|
|
||||||
if (classList.includes('lead')) {
|
|
||||||
config = { ...config, label: 'Lead Paragraph', rows: 4, placeholder: 'Enter lead paragraph...' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Override with contentType from CLI if specified
|
|
||||||
if (contentType === 'markdown') {
|
|
||||||
config = { ...config, type: 'markdown', label: 'Markdown Content', rows: 8 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create form HTML structure
|
|
||||||
*/
|
|
||||||
createEditForm(contentId, config, currentContent) {
|
|
||||||
const form = document.createElement('div');
|
|
||||||
form.className = 'insertr-edit-form';
|
|
||||||
|
|
||||||
let formHTML = `<div class="insertr-form-header">${config.label}</div>`;
|
|
||||||
|
|
||||||
if (config.type === 'markdown') {
|
|
||||||
formHTML += this.createMarkdownField(config, currentContent);
|
|
||||||
} else if (config.type === 'link' && config.includeUrl) {
|
|
||||||
formHTML += this.createLinkField(config, currentContent);
|
|
||||||
} else if (config.type === 'textarea') {
|
|
||||||
formHTML += this.createTextareaField(config, currentContent);
|
|
||||||
} else {
|
|
||||||
formHTML += this.createTextField(config, currentContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form buttons
|
|
||||||
formHTML += `
|
|
||||||
<div class="insertr-form-actions">
|
|
||||||
<button type="button" class="insertr-btn-save">Save</button>
|
|
||||||
<button type="button" class="insertr-btn-cancel">Cancel</button>
|
|
||||||
<button type="button" class="insertr-btn-history" data-content-id="${contentId}">View History</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
form.innerHTML = formHTML;
|
|
||||||
return form;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create markdown field with preview
|
|
||||||
*/
|
|
||||||
createMarkdownField(config, currentContent) {
|
|
||||||
return `
|
|
||||||
<div class="insertr-form-group">
|
|
||||||
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
|
|
||||||
rows="${config.rows || 8}"
|
|
||||||
placeholder="${config.placeholder}">${this.escapeHtml(currentContent)}</textarea>
|
|
||||||
<div class="insertr-form-help">
|
|
||||||
Supports Markdown formatting (bold, italic, links, etc.)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create link field (text + URL)
|
|
||||||
*/
|
|
||||||
createLinkField(config, currentContent) {
|
|
||||||
const linkText = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
|
|
||||||
const linkUrl = typeof currentContent === 'object' ? currentContent.url || '' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="insertr-form-group">
|
|
||||||
<label class="insertr-form-label">Link Text:</label>
|
|
||||||
<input type="text" class="insertr-form-input" name="text"
|
|
||||||
value="${this.escapeHtml(linkText)}"
|
|
||||||
placeholder="${config.placeholder}"
|
|
||||||
maxlength="${config.maxLength || 200}">
|
|
||||||
</div>
|
|
||||||
<div class="insertr-form-group">
|
|
||||||
<label class="insertr-form-label">Link URL:</label>
|
|
||||||
<input type="url" class="insertr-form-input" name="url"
|
|
||||||
value="${this.escapeHtml(linkUrl)}"
|
|
||||||
placeholder="https://example.com">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create textarea field
|
|
||||||
*/
|
|
||||||
createTextareaField(config, currentContent) {
|
|
||||||
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
|
|
||||||
return `
|
|
||||||
<div class="insertr-form-group">
|
|
||||||
<textarea class="insertr-form-textarea" name="content"
|
|
||||||
rows="${config.rows || 3}"
|
|
||||||
placeholder="${config.placeholder}"
|
|
||||||
maxlength="${config.maxLength || 1000}">${this.escapeHtml(content)}</textarea>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create text input field
|
|
||||||
*/
|
|
||||||
createTextField(config, currentContent) {
|
|
||||||
const content = typeof currentContent === 'object' ? currentContent.text || '' : currentContent;
|
|
||||||
return `
|
|
||||||
<div class="insertr-form-group">
|
|
||||||
<input type="text" class="insertr-form-input" name="content"
|
|
||||||
value="${this.escapeHtml(content)}"
|
|
||||||
placeholder="${config.placeholder}"
|
|
||||||
maxlength="${config.maxLength || 200}">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create overlay with backdrop
|
|
||||||
*/
|
|
||||||
createOverlay(form) {
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'insertr-form-overlay';
|
|
||||||
overlay.appendChild(form);
|
|
||||||
return overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get element ID for preview tracking
|
|
||||||
*/
|
|
||||||
getElementId(element) {
|
|
||||||
return element.id || element.getAttribute('data-content-id') ||
|
|
||||||
`element-${element.tagName}-${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show version history modal
|
|
||||||
*/
|
*/
|
||||||
async showVersionHistory(contentId, element, onRestore) {
|
async showVersionHistory(contentId, element, onRestore) {
|
||||||
try {
|
try {
|
||||||
// Get version history from API (we'll need to pass this in)
|
// Get version history from API
|
||||||
const apiClient = this.getApiClient();
|
const apiClient = this.getApiClient();
|
||||||
|
if (!apiClient) {
|
||||||
|
console.warn('No API client configured for version history');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const versions = await apiClient.getContentVersions(contentId);
|
const versions = await apiClient.getContentVersions(contentId);
|
||||||
|
|
||||||
// Create version history modal
|
// Create version history modal
|
||||||
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
|
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
|
||||||
document.body.appendChild(historyModal);
|
document.body.appendChild(historyModal);
|
||||||
|
|
||||||
// Focus and setup handlers
|
// Setup handlers
|
||||||
this.setupVersionHistoryHandlers(historyModal, contentId);
|
this.setupVersionHistoryHandlers(historyModal, contentId);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -478,7 +81,7 @@ export class InsertrFormRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create version history modal
|
* Create version history modal (simplified placeholder)
|
||||||
*/
|
*/
|
||||||
createVersionHistoryModal(contentId, versions, onRestore) {
|
createVersionHistoryModal(contentId, versions, onRestore) {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
@@ -547,7 +150,6 @@ export class InsertrFormRenderer {
|
|||||||
if (await this.confirmRestore()) {
|
if (await this.confirmRestore()) {
|
||||||
await this.restoreVersion(contentId, versionId);
|
await this.restoreVersion(contentId, versionId);
|
||||||
modal.remove();
|
modal.remove();
|
||||||
// Refresh the current form or close it
|
|
||||||
this.closeForm();
|
this.closeForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -626,177 +228,6 @@ export class InsertrFormRenderer {
|
|||||||
return this.apiClient || window.insertrAPIClient || null;
|
return this.apiClient || window.insertrAPIClient || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reposition modal based on current element size and ensure visibility
|
|
||||||
*/
|
|
||||||
repositionModal(element, overlay) {
|
|
||||||
// Wait for next frame to ensure DOM is updated
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const form = overlay.querySelector('.insertr-edit-form');
|
|
||||||
|
|
||||||
// Calculate new position below the current element boundaries
|
|
||||||
const newTop = rect.bottom + window.scrollY + 10;
|
|
||||||
|
|
||||||
// Update modal position
|
|
||||||
overlay.style.top = `${newTop}px`;
|
|
||||||
|
|
||||||
// After repositioning, ensure modal is still visible
|
|
||||||
this.ensureModalVisible(element, overlay);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure modal is fully visible by scrolling viewport if necessary
|
|
||||||
*/
|
|
||||||
ensureModalVisible(element, overlay) {
|
|
||||||
// Wait for next frame to ensure DOM is updated
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const modal = overlay.querySelector('.insertr-edit-form');
|
|
||||||
const modalRect = modal.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
// Calculate if modal extends below viewport
|
|
||||||
const modalBottom = modalRect.bottom;
|
|
||||||
const viewportBottom = viewportHeight;
|
|
||||||
|
|
||||||
if (modalBottom > viewportBottom) {
|
|
||||||
// Calculate scroll amount needed with some padding
|
|
||||||
const scrollAmount = modalBottom - viewportBottom + 20;
|
|
||||||
|
|
||||||
window.scrollBy({
|
|
||||||
top: scrollAmount,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup form event handlers
|
|
||||||
*/
|
|
||||||
setupFormHandlers(form, overlay, element, config, { onSave, onCancel }) {
|
|
||||||
const saveBtn = form.querySelector('.insertr-btn-save');
|
|
||||||
const cancelBtn = form.querySelector('.insertr-btn-cancel');
|
|
||||||
const elementType = this.getElementType(element, config);
|
|
||||||
|
|
||||||
// Setup live preview for input changes
|
|
||||||
this.setupLivePreview(form, element, elementType);
|
|
||||||
|
|
||||||
if (saveBtn) {
|
|
||||||
saveBtn.addEventListener('click', () => {
|
|
||||||
// Clear preview before saving (makes changes permanent)
|
|
||||||
this.previewManager.clearPreview(element);
|
|
||||||
const formData = this.extractFormData(form);
|
|
||||||
onSave(formData);
|
|
||||||
this.closeForm();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelBtn) {
|
|
||||||
cancelBtn.addEventListener('click', () => {
|
|
||||||
// Clear preview to restore original content
|
|
||||||
this.previewManager.clearPreview(element);
|
|
||||||
onCancel();
|
|
||||||
this.closeForm();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Version History button
|
|
||||||
const historyBtn = form.querySelector('.insertr-btn-history');
|
|
||||||
if (historyBtn) {
|
|
||||||
historyBtn.addEventListener('click', () => {
|
|
||||||
const contentId = historyBtn.getAttribute('data-content-id');
|
|
||||||
this.showVersionHistory(contentId, element, onSave);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC key to cancel
|
|
||||||
const keyHandler = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
this.previewManager.clearPreview(element);
|
|
||||||
onCancel();
|
|
||||||
this.closeForm();
|
|
||||||
document.removeEventListener('keydown', keyHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', keyHandler);
|
|
||||||
|
|
||||||
// Click outside to cancel
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay) {
|
|
||||||
this.previewManager.clearPreview(element);
|
|
||||||
onCancel();
|
|
||||||
this.closeForm();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setupLivePreview(form, element, elementType) {
|
|
||||||
// Get all input elements that should trigger preview updates
|
|
||||||
const inputs = form.querySelectorAll('input, textarea');
|
|
||||||
|
|
||||||
inputs.forEach(input => {
|
|
||||||
input.addEventListener('input', () => {
|
|
||||||
const newValue = this.extractInputValue(form, elementType);
|
|
||||||
this.previewManager.schedulePreview(element, newValue, elementType);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
extractInputValue(form, elementType) {
|
|
||||||
// Extract current form values for preview
|
|
||||||
const textInput = form.querySelector('input[name="text"]');
|
|
||||||
const urlInput = form.querySelector('input[name="url"]');
|
|
||||||
const contentInput = form.querySelector('input[name="content"], textarea[name="content"]');
|
|
||||||
|
|
||||||
if (textInput && urlInput) {
|
|
||||||
// Link field
|
|
||||||
return {
|
|
||||||
text: textInput.value,
|
|
||||||
url: urlInput.value
|
|
||||||
};
|
|
||||||
} else if (contentInput) {
|
|
||||||
// Text or textarea field
|
|
||||||
return contentInput.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
getElementType(element, config) {
|
|
||||||
// Determine element type for preview handling
|
|
||||||
if (config.type === 'link') return 'link';
|
|
||||||
if (config.type === 'markdown') return 'markdown';
|
|
||||||
if (config.type === 'textarea') return 'textarea';
|
|
||||||
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
return tagName === 'p' ? 'p' : 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract form data
|
|
||||||
*/
|
|
||||||
extractFormData(form) {
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
// Handle different field types
|
|
||||||
const textInput = form.querySelector('input[name="text"]');
|
|
||||||
const urlInput = form.querySelector('input[name="url"]');
|
|
||||||
const contentInput = form.querySelector('input[name="content"], textarea[name="content"]');
|
|
||||||
|
|
||||||
if (textInput && urlInput) {
|
|
||||||
// Link field
|
|
||||||
data.text = textInput.value;
|
|
||||||
data.url = urlInput.value;
|
|
||||||
} else if (contentInput) {
|
|
||||||
// Text or textarea field
|
|
||||||
data.text = contentInput.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape HTML to prevent XSS
|
* Escape HTML to prevent XSS
|
||||||
*/
|
*/
|
||||||
@@ -808,10 +239,11 @@ export class InsertrFormRenderer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup form styles
|
* Setup form styles (consolidated and simplified)
|
||||||
*/
|
*/
|
||||||
setupStyles() {
|
setupStyles() {
|
||||||
const styles = `
|
const styles = `
|
||||||
|
/* Overlay and Form Container */
|
||||||
.insertr-form-overlay {
|
.insertr-form-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
@@ -826,8 +258,11 @@ export class InsertrFormRenderer {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
min-width: 600px;
|
||||||
|
max-width: 800px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form Header */
|
||||||
.insertr-form-header {
|
.insertr-form-header {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
@@ -839,6 +274,7 @@ export class InsertrFormRenderer {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form Groups and Fields */
|
||||||
.insertr-form-group {
|
.insertr-form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -874,6 +310,7 @@ export class InsertrFormRenderer {
|
|||||||
box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
|
box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown Editor Styling */
|
||||||
.insertr-form-textarea {
|
.insertr-form-textarea {
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -888,6 +325,7 @@ export class InsertrFormRenderer {
|
|||||||
background-color: #f8fafc;
|
background-color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form Actions */
|
||||||
.insertr-form-actions {
|
.insertr-form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -929,6 +367,22 @@ export class InsertrFormRenderer {
|
|||||||
background: #4b5563;
|
background: #4b5563;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insertr-btn-history {
|
||||||
|
background: #6f42c1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-history:hover {
|
||||||
|
background: #5a359a;
|
||||||
|
}
|
||||||
|
|
||||||
.insertr-form-help {
|
.insertr-form-help {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
@@ -960,12 +414,7 @@ export class InsertrFormRenderer {
|
|||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced modal sizing for comfortable editing */
|
/* Responsive Design */
|
||||||
.insertr-edit-form {
|
|
||||||
min-width: 600px; /* Ensures ~70 character width */
|
|
||||||
max-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.insertr-edit-form {
|
.insertr-edit-form {
|
||||||
min-width: 90vw;
|
min-width: 90vw;
|
||||||
@@ -979,10 +428,159 @@ export class InsertrFormRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced input styling for comfortable editing */
|
/* Version History Modal Styles */
|
||||||
.insertr-form-input {
|
.insertr-version-modal {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
position: fixed;
|
||||||
letter-spacing: 0.02em;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-content-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-header {
|
||||||
|
padding: 20px 20px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-header h3 {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-item {
|
||||||
|
border: 1px solid #e1e5e9;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #0969da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-date {
|
||||||
|
color: #656d76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-user {
|
||||||
|
color: #656d76;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-content {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 8px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #24292f;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-restore {
|
||||||
|
background: #0969da;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-restore:hover {
|
||||||
|
background: #0860ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-view-diff {
|
||||||
|
background: #f6f8fa;
|
||||||
|
color: #24292f;
|
||||||
|
border: 1px solid #d1d9e0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-btn-view-diff:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insertr-version-empty {
|
||||||
|
text-align: center;
|
||||||
|
color: #656d76;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 40px 20px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -1,446 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unified Markdown Editor - Handles both single and multiple element editing
|
|
||||||
*/
|
|
||||||
import { markdownConverter } from '../utils/markdown.js';
|
|
||||||
|
|
||||||
export class MarkdownEditor {
|
|
||||||
constructor() {
|
|
||||||
this.currentOverlay = null;
|
|
||||||
this.previewManager = new MarkdownPreviewManager();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edit elements with markdown - unified interface for single or multiple elements
|
|
||||||
* @param {HTMLElement|HTMLElement[]} elements - Element(s) to edit
|
|
||||||
* @param {Function} onSave - Save callback
|
|
||||||
* @param {Function} onCancel - Cancel callback
|
|
||||||
*/
|
|
||||||
edit(elements, onSave, onCancel) {
|
|
||||||
// Normalize to array
|
|
||||||
const elementArray = Array.isArray(elements) ? elements : [elements];
|
|
||||||
const context = new MarkdownContext(elementArray);
|
|
||||||
|
|
||||||
// Close any existing editor
|
|
||||||
this.close();
|
|
||||||
|
|
||||||
// Create unified editor form
|
|
||||||
const form = this.createMarkdownForm(context);
|
|
||||||
const overlay = this.createOverlay(form);
|
|
||||||
|
|
||||||
// Position relative to primary element
|
|
||||||
this.positionForm(context.primaryElement, overlay);
|
|
||||||
|
|
||||||
// Setup unified event handlers
|
|
||||||
this.setupEventHandlers(form, overlay, context, { onSave, onCancel });
|
|
||||||
|
|
||||||
// Show editor
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
this.currentOverlay = overlay;
|
|
||||||
|
|
||||||
// Focus textarea
|
|
||||||
const textarea = form.querySelector('textarea');
|
|
||||||
if (textarea) {
|
|
||||||
setTimeout(() => textarea.focus(), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
return overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create markdown editing form
|
|
||||||
*/
|
|
||||||
createMarkdownForm(context) {
|
|
||||||
const config = this.getMarkdownConfig(context);
|
|
||||||
const currentContent = context.extractMarkdown();
|
|
||||||
|
|
||||||
const form = document.createElement('div');
|
|
||||||
form.className = 'insertr-edit-form';
|
|
||||||
|
|
||||||
form.innerHTML = `
|
|
||||||
<div class="insertr-form-header">${config.label}</div>
|
|
||||||
<div class="insertr-form-group">
|
|
||||||
<textarea class="insertr-form-textarea insertr-markdown-editor" name="content"
|
|
||||||
rows="${config.rows}"
|
|
||||||
placeholder="${config.placeholder}">${this.escapeHtml(currentContent)}</textarea>
|
|
||||||
<div class="insertr-form-help">
|
|
||||||
Supports Markdown formatting (bold, italic, links, etc.)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="insertr-form-actions">
|
|
||||||
<button type="button" class="insertr-btn-save">Save</button>
|
|
||||||
<button type="button" class="insertr-btn-cancel">Cancel</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
return form;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get markdown configuration based on context
|
|
||||||
*/
|
|
||||||
getMarkdownConfig(context) {
|
|
||||||
const elementCount = context.elements.length;
|
|
||||||
|
|
||||||
if (elementCount === 1) {
|
|
||||||
const element = context.elements[0];
|
|
||||||
const tag = element.tagName.toLowerCase();
|
|
||||||
|
|
||||||
switch (tag) {
|
|
||||||
case 'h3': case 'h4': case 'h5': case 'h6':
|
|
||||||
return {
|
|
||||||
label: 'Title (Markdown)',
|
|
||||||
rows: 2,
|
|
||||||
placeholder: 'Enter title using markdown...'
|
|
||||||
};
|
|
||||||
case 'p':
|
|
||||||
return {
|
|
||||||
label: 'Content (Markdown)',
|
|
||||||
rows: 4,
|
|
||||||
placeholder: 'Enter content using markdown...'
|
|
||||||
};
|
|
||||||
case 'span':
|
|
||||||
return {
|
|
||||||
label: 'Text (Markdown)',
|
|
||||||
rows: 2,
|
|
||||||
placeholder: 'Enter text using markdown...'
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
label: 'Content (Markdown)',
|
|
||||||
rows: 3,
|
|
||||||
placeholder: 'Enter content using markdown...'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
label: `Group Content (${elementCount} elements)`,
|
|
||||||
rows: Math.max(8, elementCount * 2),
|
|
||||||
placeholder: 'Edit all content together using markdown...'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Setup unified event handlers
|
|
||||||
*/
|
|
||||||
setupEventHandlers(form, overlay, context, { onSave, onCancel }) {
|
|
||||||
const textarea = form.querySelector('textarea');
|
|
||||||
const saveBtn = form.querySelector('.insertr-btn-save');
|
|
||||||
const cancelBtn = form.querySelector('.insertr-btn-cancel');
|
|
||||||
|
|
||||||
// Initialize preview manager
|
|
||||||
this.previewManager.setActiveContext(context);
|
|
||||||
|
|
||||||
// Setup debounced live preview
|
|
||||||
if (textarea) {
|
|
||||||
textarea.addEventListener('input', () => {
|
|
||||||
const markdown = textarea.value;
|
|
||||||
this.previewManager.schedulePreview(context, markdown);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save handler
|
|
||||||
if (saveBtn) {
|
|
||||||
saveBtn.addEventListener('click', () => {
|
|
||||||
const markdown = textarea.value;
|
|
||||||
|
|
||||||
// Update elements with final content
|
|
||||||
context.applyMarkdown(markdown);
|
|
||||||
|
|
||||||
// Clear preview styling
|
|
||||||
this.previewManager.clearPreview();
|
|
||||||
|
|
||||||
// Callback and close
|
|
||||||
onSave({ text: markdown });
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel handler
|
|
||||||
if (cancelBtn) {
|
|
||||||
cancelBtn.addEventListener('click', () => {
|
|
||||||
this.previewManager.clearPreview();
|
|
||||||
onCancel();
|
|
||||||
this.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ESC key handler
|
|
||||||
const keyHandler = (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
this.previewManager.clearPreview();
|
|
||||||
onCancel();
|
|
||||||
this.close();
|
|
||||||
document.removeEventListener('keydown', keyHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener('keydown', keyHandler);
|
|
||||||
|
|
||||||
// Click outside handler
|
|
||||||
overlay.addEventListener('click', (e) => {
|
|
||||||
if (e.target === overlay) {
|
|
||||||
this.previewManager.clearPreview();
|
|
||||||
onCancel();
|
|
||||||
this.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create overlay with backdrop
|
|
||||||
*/
|
|
||||||
createOverlay(form) {
|
|
||||||
const overlay = document.createElement('div');
|
|
||||||
overlay.className = 'insertr-form-overlay';
|
|
||||||
overlay.appendChild(form);
|
|
||||||
return overlay;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Position form relative to primary element
|
|
||||||
*/
|
|
||||||
positionForm(element, overlay) {
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const form = overlay.querySelector('.insertr-edit-form');
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
|
|
||||||
// Calculate optimal width
|
|
||||||
let formWidth;
|
|
||||||
if (viewportWidth < 768) {
|
|
||||||
formWidth = Math.min(viewportWidth - 40, 500);
|
|
||||||
} else {
|
|
||||||
const minComfortableWidth = 600;
|
|
||||||
const maxWidth = Math.min(viewportWidth * 0.9, 800);
|
|
||||||
formWidth = Math.max(minComfortableWidth, Math.min(rect.width * 1.5, maxWidth));
|
|
||||||
}
|
|
||||||
|
|
||||||
form.style.width = `${formWidth}px`;
|
|
||||||
|
|
||||||
// Position below element
|
|
||||||
const top = rect.bottom + window.scrollY + 10;
|
|
||||||
const centerLeft = rect.left + window.scrollX + (rect.width / 2) - (formWidth / 2);
|
|
||||||
const minLeft = 20;
|
|
||||||
const maxLeft = window.innerWidth - formWidth - 20;
|
|
||||||
const left = Math.max(minLeft, Math.min(centerLeft, maxLeft));
|
|
||||||
|
|
||||||
overlay.style.position = 'absolute';
|
|
||||||
overlay.style.top = `${top}px`;
|
|
||||||
overlay.style.left = `${left}px`;
|
|
||||||
overlay.style.zIndex = '10000';
|
|
||||||
|
|
||||||
// Ensure visibility
|
|
||||||
this.ensureModalVisible(element, overlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure modal is visible by scrolling if needed
|
|
||||||
*/
|
|
||||||
ensureModalVisible(element, overlay) {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const modal = overlay.querySelector('.insertr-edit-form');
|
|
||||||
const modalRect = modal.getBoundingClientRect();
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
|
|
||||||
if (modalRect.bottom > viewportHeight) {
|
|
||||||
const scrollAmount = modalRect.bottom - viewportHeight + 20;
|
|
||||||
window.scrollBy({
|
|
||||||
top: scrollAmount,
|
|
||||||
behavior: 'smooth'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Close current editor
|
|
||||||
*/
|
|
||||||
close() {
|
|
||||||
if (this.previewManager) {
|
|
||||||
this.previewManager.clearPreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.currentOverlay) {
|
|
||||||
this.currentOverlay.remove();
|
|
||||||
this.currentOverlay = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Escape HTML to prevent XSS
|
|
||||||
*/
|
|
||||||
escapeHtml(text) {
|
|
||||||
if (typeof text !== 'string') return '';
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Markdown Context - Represents single or multiple elements for editing
|
|
||||||
*/
|
|
||||||
class MarkdownContext {
|
|
||||||
constructor(elements) {
|
|
||||||
this.elements = elements;
|
|
||||||
this.primaryElement = elements[0]; // Used for positioning
|
|
||||||
this.originalContent = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract markdown content from elements
|
|
||||||
*/
|
|
||||||
extractMarkdown() {
|
|
||||||
if (this.elements.length === 1) {
|
|
||||||
// Single element - convert its HTML to markdown
|
|
||||||
return markdownConverter.htmlToMarkdown(this.elements[0].innerHTML);
|
|
||||||
} else {
|
|
||||||
// Multiple elements - combine and convert to markdown
|
|
||||||
return markdownConverter.extractGroupMarkdown(this.elements);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply markdown content to elements
|
|
||||||
*/
|
|
||||||
applyMarkdown(markdown) {
|
|
||||||
if (this.elements.length === 1) {
|
|
||||||
// Single element - convert markdown to HTML and apply
|
|
||||||
const html = markdownConverter.markdownToHtml(markdown);
|
|
||||||
this.elements[0].innerHTML = html;
|
|
||||||
} else {
|
|
||||||
// Multiple elements - use group update logic
|
|
||||||
markdownConverter.updateGroupElements(this.elements, markdown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store original content for preview restoration
|
|
||||||
*/
|
|
||||||
storeOriginalContent() {
|
|
||||||
this.originalContent = this.elements.map(el => el.innerHTML);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restore original content (for preview cancellation)
|
|
||||||
*/
|
|
||||||
restoreOriginalContent() {
|
|
||||||
if (this.originalContent) {
|
|
||||||
this.elements.forEach((el, index) => {
|
|
||||||
if (this.originalContent[index] !== undefined) {
|
|
||||||
el.innerHTML = this.originalContent[index];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply preview styling
|
|
||||||
*/
|
|
||||||
applyPreviewStyling() {
|
|
||||||
this.elements.forEach(el => {
|
|
||||||
el.classList.add('insertr-preview-active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also apply to primary element if it's a container
|
|
||||||
if (this.primaryElement.classList.contains('insertr-group')) {
|
|
||||||
this.primaryElement.classList.add('insertr-preview-active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove preview styling
|
|
||||||
*/
|
|
||||||
removePreviewStyling() {
|
|
||||||
this.elements.forEach(el => {
|
|
||||||
el.classList.remove('insertr-preview-active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also remove from containers
|
|
||||||
if (this.primaryElement.classList.contains('insertr-group')) {
|
|
||||||
this.primaryElement.classList.remove('insertr-preview-active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified Preview Manager for Markdown Content
|
|
||||||
*/
|
|
||||||
class MarkdownPreviewManager {
|
|
||||||
constructor() {
|
|
||||||
this.previewTimeout = null;
|
|
||||||
this.activeContext = null;
|
|
||||||
this.resizeObserver = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveContext(context) {
|
|
||||||
this.clearPreview();
|
|
||||||
this.activeContext = context;
|
|
||||||
this.startResizeObserver();
|
|
||||||
}
|
|
||||||
|
|
||||||
schedulePreview(context, markdown) {
|
|
||||||
// Clear existing timeout
|
|
||||||
if (this.previewTimeout) {
|
|
||||||
clearTimeout(this.previewTimeout);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Schedule new preview with 500ms debounce
|
|
||||||
this.previewTimeout = setTimeout(() => {
|
|
||||||
this.updatePreview(context, markdown);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
updatePreview(context, markdown) {
|
|
||||||
// Store original content if first preview
|
|
||||||
if (!context.originalContent) {
|
|
||||||
context.storeOriginalContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply preview content
|
|
||||||
context.applyMarkdown(markdown);
|
|
||||||
context.applyPreviewStyling();
|
|
||||||
}
|
|
||||||
|
|
||||||
clearPreview() {
|
|
||||||
if (this.activeContext) {
|
|
||||||
this.activeContext.restoreOriginalContent();
|
|
||||||
this.activeContext.removePreviewStyling();
|
|
||||||
this.activeContext = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.previewTimeout) {
|
|
||||||
clearTimeout(this.previewTimeout);
|
|
||||||
this.previewTimeout = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.stopResizeObserver();
|
|
||||||
}
|
|
||||||
|
|
||||||
startResizeObserver() {
|
|
||||||
this.stopResizeObserver();
|
|
||||||
|
|
||||||
if (this.activeContext) {
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
|
||||||
// Handle height changes for modal repositioning
|
|
||||||
if (this.onHeightChange) {
|
|
||||||
this.onHeightChange(this.activeContext.primaryElement);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.activeContext.elements.forEach(el => {
|
|
||||||
this.resizeObserver.observe(el);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stopResizeObserver() {
|
|
||||||
if (this.resizeObserver) {
|
|
||||||
this.resizeObserver.disconnect();
|
|
||||||
this.resizeObserver = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setHeightChangeCallback(callback) {
|
|
||||||
this.onHeightChange = callback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Test ID Generation</title>
|
|
||||||
<script src="demo-site/insertr.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<section class="hero">
|
|
||||||
<h1 class="insertr">Transform Your Business with Expert Consulting</h1>
|
|
||||||
<p class="lead insertr">We help small businesses grow through strategic planning, process optimization, and digital transformation. Our team brings 15+ years of experience to drive your success.</p>
|
|
||||||
<a href="contact.html" class="btn-primary insertr">Get Started Today</a>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const core = new InsertrCore();
|
|
||||||
const elements = core.findEnhancedElements();
|
|
||||||
|
|
||||||
console.log('Testing ID generation:');
|
|
||||||
elements.forEach(element => {
|
|
||||||
const metadata = core.getElementMetadata(element);
|
|
||||||
console.log(`${element.tagName.toLowerCase()}: "${element.textContent.trim()}" => ${metadata.contentId}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expected IDs from CLI:
|
|
||||||
console.log('\nExpected from CLI:');
|
|
||||||
console.log('h1: hero-title-7cfeea');
|
|
||||||
console.log('p: hero-lead-e47475');
|
|
||||||
console.log('a: hero-link-76c620');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Reference in New Issue
Block a user