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)
|
||||
- `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
|
||||
|
||||
## 🔄 **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/insertr/insertr/internal/api"
|
||||
"github.com/insertr/insertr/internal/auth"
|
||||
"github.com/insertr/insertr/internal/db"
|
||||
)
|
||||
|
||||
@@ -51,8 +52,24 @@ func runServe(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
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
|
||||
contentHandler := api.NewContentHandler(database)
|
||||
contentHandler := api.NewContentHandler(database, authService)
|
||||
|
||||
// Setup router
|
||||
router := mux.NewRouter()
|
||||
|
||||
@@ -4,7 +4,7 @@ CREATE TABLE content (
|
||||
id TEXT NOT NULL,
|
||||
site_id 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,
|
||||
updated_at BIGINT DEFAULT EXTRACT(EPOCH FROM NOW()) 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)
|
||||
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
|
||||
DELETE FROM content
|
||||
WHERE id = sqlc.arg(id) AND site_id = sqlc.arg(site_id);
|
||||
@@ -4,7 +4,7 @@ CREATE TABLE content (
|
||||
id TEXT NOT NULL,
|
||||
site_id 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,
|
||||
updated_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL,
|
||||
last_edited_by TEXT DEFAULT 'system' NOT NULL,
|
||||
|
||||
1
go.mod
1
go.mod
@@ -13,6 +13,7 @@ require (
|
||||
|
||||
require (
|
||||
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/inconshreveable/mousetrap v1.1.0 // 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/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/insertr/insertr/internal/auth"
|
||||
"github.com/insertr/insertr/internal/db"
|
||||
"github.com/insertr/insertr/internal/db/postgresql"
|
||||
"github.com/insertr/insertr/internal/db/sqlite"
|
||||
@@ -18,13 +19,15 @@ import (
|
||||
|
||||
// ContentHandler handles all content-related HTTP requests
|
||||
type ContentHandler struct {
|
||||
database *db.Database
|
||||
database *db.Database
|
||||
authService *auth.AuthService
|
||||
}
|
||||
|
||||
// NewContentHandler creates a new content handler
|
||||
func NewContentHandler(database *db.Database) *ContentHandler {
|
||||
func NewContentHandler(database *db.Database, authService *auth.AuthService) *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
|
||||
}
|
||||
|
||||
// Extract user from request (for now, use X-User-ID header or fallback)
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" && req.CreatedBy != "" {
|
||||
userID = req.CreatedBy
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := userInfo.ID
|
||||
|
||||
var content interface{}
|
||||
var err error
|
||||
@@ -219,7 +221,7 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
vars := mux.Vars(r)
|
||||
contentID := vars["id"]
|
||||
@@ -236,29 +238,70 @@ func (h *ContentHandler) UpdateContent(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user from request
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" && req.UpdatedBy != "" {
|
||||
userID = req.UpdatedBy
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
userID := userInfo.ID
|
||||
|
||||
// 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
|
||||
var currentContent interface{}
|
||||
// Archive existing version before upsert (only if content already exists)
|
||||
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
|
||||
|
||||
switch h.database.GetDBType() {
|
||||
case "sqlite3":
|
||||
currentContent, err = h.database.GetSQLiteQueries().GetContent(context.Background(), sqlite.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
upsertedContent, err = h.database.GetSQLiteQueries().UpsertContent(context.Background(), sqlite.UpsertContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
Value: req.Value,
|
||||
Type: contentType,
|
||||
LastEditedBy: userID,
|
||||
})
|
||||
case "postgresql":
|
||||
currentContent, err = h.database.GetPostgreSQLQueries().GetContent(context.Background(), postgresql.GetContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
upsertedContent, err = h.database.GetPostgreSQLQueries().UpsertContent(context.Background(), postgresql.UpsertContentParams{
|
||||
ID: contentID,
|
||||
SiteID: siteID,
|
||||
Value: req.Value,
|
||||
Type: contentType,
|
||||
LastEditedBy: userID,
|
||||
})
|
||||
default:
|
||||
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 == sql.ErrNoRows {
|
||||
http.Error(w, "Content not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError)
|
||||
http.Error(w, fmt.Sprintf("Failed to upsert content: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Archive current version before updating
|
||||
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)
|
||||
item := h.convertToAPIContent(upsertedContent)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(item)
|
||||
@@ -459,14 +455,13 @@ func (h *ContentHandler) RollbackContent(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user from request
|
||||
userID := r.Header.Get("X-User-ID")
|
||||
if userID == "" && req.RolledBackBy != "" {
|
||||
userID = req.RolledBackBy
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
// Extract user from request using authentication service
|
||||
userInfo, authErr := h.authService.ExtractUserFromRequest(r)
|
||||
if authErr != nil {
|
||||
http.Error(w, fmt.Sprintf("Authentication error: %v", authErr), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID := userInfo.ID
|
||||
|
||||
// Archive current version before rollback
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
InitializeVersionsTable(ctx context.Context) error
|
||||
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
|
||||
UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error)
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
|
||||
@@ -212,3 +212,42 @@ func (q *Queries) UpdateContent(ctx context.Context, arg UpdateContentParams) (C
|
||||
)
|
||||
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
|
||||
InitializeVersionsTable(ctx context.Context) error
|
||||
UpdateContent(ctx context.Context, arg UpdateContentParams) (Content, error)
|
||||
UpsertContent(ctx context.Context, arg UpsertContentParams) (Content, error)
|
||||
}
|
||||
|
||||
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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': this.getCurrentUser()
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({ value: content })
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export class ApiClient {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': this.getCurrentUser()
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: contentId,
|
||||
@@ -113,7 +113,7 @@ export class ApiClient {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-User-ID': this.getCurrentUser()
|
||||
'Authorization': `Bearer ${this.getAuthToken()}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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() {
|
||||
// This could be enhanced to get from authentication system
|
||||
return 'anonymous';
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
this.formRenderer.closeForm();
|
||||
@@ -127,8 +126,7 @@ export class InsertrEditor {
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving content:', error);
|
||||
|
||||
// Still update the UI even if API fails
|
||||
this.updateElementContent(meta.element, formData);
|
||||
|
||||
this.formRenderer.closeForm();
|
||||
}
|
||||
}
|
||||
@@ -140,44 +138,15 @@ export class InsertrEditor {
|
||||
return 'link';
|
||||
}
|
||||
|
||||
if (tagName === 'p' || tagName === 'div') {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
// Default to text for headings and other elements
|
||||
return 'text';
|
||||
// ALL text elements use markdown for consistent editing experience
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
handleCancel(meta) {
|
||||
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() {
|
||||
const styles = `
|
||||
.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 {
|
||||
constructor() {
|
||||
this.previewTimeouts = new Map();
|
||||
this.activeElement = null;
|
||||
this.originalContent = null;
|
||||
this.originalStyles = null;
|
||||
this.resizeObserver = null;
|
||||
this.onHeightChangeCallback = null;
|
||||
}
|
||||
import { Editor } from './Editor.js';
|
||||
|
||||
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 {
|
||||
constructor(apiClient = null) {
|
||||
this.apiClient = apiClient;
|
||||
this.currentOverlay = null;
|
||||
this.previewManager = new LivePreviewManager();
|
||||
this.markdownEditor = new MarkdownEditor();
|
||||
this.editor = new Editor();
|
||||
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 {string} currentContent - Current content value
|
||||
* @param {string|Object} currentContent - Current content value
|
||||
* @param {Function} onSave - Save callback
|
||||
* @param {Function} onCancel - Cancel callback
|
||||
*/
|
||||
showEditForm(meta, currentContent, onSave, onCancel) {
|
||||
// Close any existing form
|
||||
this.closeForm();
|
||||
const { element } = meta;
|
||||
|
||||
const { element, contentId, contentType } = meta;
|
||||
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
|
||||
// Handle insertr-group elements by getting their viable children
|
||||
if (element.classList.contains('insertr-group')) {
|
||||
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
|
||||
return this.showLegacyEditForm(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;
|
||||
// All other elements use the editor directly
|
||||
return this.editor.edit(meta, currentContent, onSave, onCancel);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -273,7 +38,7 @@ export class InsertrFormRenderer {
|
||||
getGroupChildren(groupElement) {
|
||||
const 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) {
|
||||
children.push(child);
|
||||
}
|
||||
@@ -285,190 +50,28 @@ export class InsertrFormRenderer {
|
||||
* Close current form
|
||||
*/
|
||||
closeForm() {
|
||||
// Close markdown editor if active
|
||||
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;
|
||||
}
|
||||
this.editor.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate field configuration based on element
|
||||
*/
|
||||
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
|
||||
* Show version history modal (placeholder for future implementation)
|
||||
*/
|
||||
async showVersionHistory(contentId, element, onRestore) {
|
||||
try {
|
||||
// Get version history from API (we'll need to pass this in)
|
||||
// Get version history from API
|
||||
const apiClient = this.getApiClient();
|
||||
const versions = await apiClient.getContentVersions(contentId);
|
||||
if (!apiClient) {
|
||||
console.warn('No API client configured for version history');
|
||||
return;
|
||||
}
|
||||
|
||||
const versions = await apiClient.getContentVersions(contentId);
|
||||
|
||||
// Create version history modal
|
||||
const historyModal = this.createVersionHistoryModal(contentId, versions, onRestore);
|
||||
document.body.appendChild(historyModal);
|
||||
|
||||
// Focus and setup handlers
|
||||
|
||||
// Setup handlers
|
||||
this.setupVersionHistoryHandlers(historyModal, contentId);
|
||||
|
||||
} catch (error) {
|
||||
@@ -478,7 +81,7 @@ export class InsertrFormRenderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create version history modal
|
||||
* Create version history modal (simplified placeholder)
|
||||
*/
|
||||
createVersionHistoryModal(contentId, versions, onRestore) {
|
||||
const modal = document.createElement('div');
|
||||
@@ -547,7 +150,6 @@ export class InsertrFormRenderer {
|
||||
if (await this.confirmRestore()) {
|
||||
await this.restoreVersion(contentId, versionId);
|
||||
modal.remove();
|
||||
// Refresh the current form or close it
|
||||
this.closeForm();
|
||||
}
|
||||
});
|
||||
@@ -626,177 +228,6 @@ export class InsertrFormRenderer {
|
||||
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
|
||||
*/
|
||||
@@ -808,10 +239,11 @@ export class InsertrFormRenderer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup form styles
|
||||
* Setup form styles (consolidated and simplified)
|
||||
*/
|
||||
setupStyles() {
|
||||
const styles = `
|
||||
/* Overlay and Form Container */
|
||||
.insertr-form-overlay {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
@@ -826,8 +258,11 @@ export class InsertrFormRenderer {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
min-width: 600px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Form Header */
|
||||
.insertr-form-header {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
@@ -839,6 +274,7 @@ export class InsertrFormRenderer {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Form Groups and Fields */
|
||||
.insertr-form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -874,6 +310,7 @@ export class InsertrFormRenderer {
|
||||
box-shadow: 0 0 0 3px rgba(0, 124, 186, 0.1);
|
||||
}
|
||||
|
||||
/* Markdown Editor Styling */
|
||||
.insertr-form-textarea {
|
||||
min-height: 120px;
|
||||
resize: vertical;
|
||||
@@ -888,6 +325,7 @@ export class InsertrFormRenderer {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Form Actions */
|
||||
.insertr-form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -929,6 +367,22 @@ export class InsertrFormRenderer {
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
@@ -960,12 +414,7 @@ export class InsertrFormRenderer {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
/* Enhanced modal sizing for comfortable editing */
|
||||
.insertr-edit-form {
|
||||
min-width: 600px; /* Ensures ~70 character width */
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.insertr-edit-form {
|
||||
min-width: 90vw;
|
||||
@@ -979,10 +428,159 @@ export class InsertrFormRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced input styling for comfortable editing */
|
||||
.insertr-form-input {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace;
|
||||
letter-spacing: 0.02em;
|
||||
/* Version History Modal Styles */
|
||||
.insertr-version-modal {
|
||||
position: fixed;
|
||||
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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -991,4 +589,4 @@ export class InsertrFormRenderer {
|
||||
styleSheet.innerHTML = styles;
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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