Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c60bdaf9 | ||
|
|
5e670119db | ||
|
|
3223989a9d | ||
|
|
f557d319ca | ||
|
|
90bdf4b725 | ||
|
|
1bbae698d5 | ||
|
|
1b02eecdab |
+32
-3
@@ -263,6 +263,36 @@ paths:
|
|||||||
"204": { description: Saved. }
|
"204": { description: Saved. }
|
||||||
"400": { $ref: "#/components/responses/BadRequest" }
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
|
||||||
|
/api/v1/auth/nav-order:
|
||||||
|
put:
|
||||||
|
tags: [auth]
|
||||||
|
summary: The order of the fixed views in the sidebar.
|
||||||
|
description: |
|
||||||
|
I dag, Kommende, Venter and the inbox, in whatever order this person wants
|
||||||
|
them. On the account rather than in the browser: which order you want them
|
||||||
|
in is a fact about you and should be the same on the phone as on the laptop.
|
||||||
|
|
||||||
|
A list of keys rather than a number per view. The set of views changes with
|
||||||
|
the program, and a key that no longer exists costs nothing — the sidebar
|
||||||
|
draws what it recognises and appends anything new at the end, which is what
|
||||||
|
makes adding a view later not a migration. An empty list means the order the
|
||||||
|
program ships with.
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
order:
|
||||||
|
type: array
|
||||||
|
maxItems: 20
|
||||||
|
items: { type: string }
|
||||||
|
example: ["inbox", "today", "upcoming", "delegated"]
|
||||||
|
responses:
|
||||||
|
"204": { description: Saved. }
|
||||||
|
"400": { $ref: "#/components/responses/BadRequest" }
|
||||||
|
|
||||||
/api/v1/auth/me:
|
/api/v1/auth/me:
|
||||||
get:
|
get:
|
||||||
tags: [auth]
|
tags: [auth]
|
||||||
@@ -3278,7 +3308,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: { type: string }
|
id: { type: string }
|
||||||
project_id: { type: string }
|
project_id: { type: string }
|
||||||
title: { type: string, description: "Taken from the first line when not given." }
|
title: { type: string, readOnly: true, description: "The note's first line. Derived on every save; it cannot be set." }
|
||||||
body: { type: string, description: Markdown }
|
body: { type: string, description: Markdown }
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
created_at: { type: string, format: date-time }
|
created_at: { type: string, format: date-time }
|
||||||
@@ -3296,8 +3326,7 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
project_id: { type: string, description: "Empty files it under no project." }
|
project_id: { type: string, description: "Empty files it under no project." }
|
||||||
title: { type: string }
|
body: { type: string, description: "Markdown. Its first line becomes the title." }
|
||||||
body: { type: string }
|
|
||||||
pinned: { type: boolean }
|
pinned: { type: boolean }
|
||||||
|
|
||||||
Mailbox:
|
Mailbox:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type userJSON struct {
|
|||||||
// about the work and not about the screen — the sidebar's width is the
|
// about the work and not about the screen — the sidebar's width is the
|
||||||
// opposite case and stays in localStorage.
|
// opposite case and stays in localStorage.
|
||||||
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
SidebarCollapsed []string `json:"sidebar_collapsed"`
|
||||||
|
NavOrder []string `json:"nav_order"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,10 +43,14 @@ func toUserJSON(u *store.User) userJSON {
|
|||||||
if collapsed == nil {
|
if collapsed == nil {
|
||||||
collapsed = []string{}
|
collapsed = []string{}
|
||||||
}
|
}
|
||||||
|
order := u.NavOrder
|
||||||
|
if order == nil {
|
||||||
|
order = []string{}
|
||||||
|
}
|
||||||
return userJSON{
|
return userJSON{
|
||||||
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
ID: u.ID, Email: u.Email, Name: u.Name, AvatarColor: u.AvatarColor,
|
||||||
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
Timezone: u.Timezone, Locale: u.Locale, IsAdmin: u.IsAdmin,
|
||||||
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed,
|
TOTPEnabled: u.TOTPEnabled, SidebarCollapsed: collapsed, NavOrder: order,
|
||||||
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
CreatedAt: u.CreatedAt.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,30 @@ func (s *Server) handleSetSidebarSections(w http.ResponseWriter, r *http.Request
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleSetNavOrder records the order of the fixed views in the sidebar.
|
||||||
|
//
|
||||||
|
// Its own route for the same reason as the folds: this writes on every drop, and
|
||||||
|
// sharing the profile's path would make a reorder send a name and a timezone it
|
||||||
|
// was never asked to change.
|
||||||
|
func (s *Server) handleSetNavOrder(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Order []string `json:"order"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &req); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Order) > 20 {
|
||||||
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "too many entries")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := userFrom(r.Context())
|
||||||
|
if err := s.db.SetNavOrder(r.Context(), user.ID, req.Order); err != nil {
|
||||||
|
s.storeError(w, r, "set nav order", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// --- sign in -----------------------------------------------------------------
|
// --- sign in -----------------------------------------------------------------
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
|
|||||||
@@ -530,8 +530,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
|||||||
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
"links to a note by its title. Use this to bring notes in from elsewhere — " +
|
||||||
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
"Apple Notes, OneNote, a folder of Markdown — one call per note.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"body": mcp.Str("The note as Markdown. #Projekt and [[note]] become links."),
|
"body": mcp.Str("The note as Markdown. Its first line becomes the title, so start with one. #Projekt and [[note]] become links."),
|
||||||
"title": mcp.Str("A title. Left out, the first line of the body becomes it."),
|
|
||||||
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
"project_id": mcp.Str("File it under a project by id. Usually unnecessary — a #tag in the body does the same and is what a person would type."),
|
||||||
"pinned": mcp.Bool("Keep it at the top of the list."),
|
"pinned": mcp.Bool("Keep it at the top of the list."),
|
||||||
}),
|
}),
|
||||||
@@ -545,8 +544,7 @@ func (s *Server) registerNoteTools(m *mcp.Server) {
|
|||||||
"removed.",
|
"removed.",
|
||||||
InputSchema: mcp.Schema(map[string]any{
|
InputSchema: mcp.Schema(map[string]any{
|
||||||
"note_id": mcp.Str("Which note."),
|
"note_id": mcp.Str("Which note."),
|
||||||
"body": mcp.Str("The whole new text, as Markdown."),
|
"body": mcp.Str("The whole new text, as Markdown. The title follows its first line."),
|
||||||
"title": mcp.Str("A new title."),
|
|
||||||
"pinned": mcp.Bool("Pin it or unpin it."),
|
"pinned": mcp.Bool("Pin it or unpin it."),
|
||||||
}),
|
}),
|
||||||
}, s.mcpUpdateNote)
|
}, s.mcpUpdateNote)
|
||||||
@@ -604,7 +602,6 @@ func (s *Server) mcpMayRead(ctx context.Context, n *store.Note, userID string) b
|
|||||||
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.RawMessage) (any, error) {
|
||||||
var params struct {
|
var params struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
Title string `json:"title"`
|
|
||||||
ProjectID string `json:"project_id"`
|
ProjectID string `json:"project_id"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
@@ -612,7 +609,6 @@ func (s *Server) mcpCreateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
|
|
||||||
n := &store.Note{
|
n := &store.Note{
|
||||||
CreatedBy: userID,
|
CreatedBy: userID,
|
||||||
Title: params.Title,
|
|
||||||
Body: params.Body,
|
Body: params.Body,
|
||||||
Pinned: params.Pinned,
|
Pinned: params.Pinned,
|
||||||
}
|
}
|
||||||
@@ -638,7 +634,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
var params struct {
|
var params struct {
|
||||||
NoteID string `json:"note_id"`
|
NoteID string `json:"note_id"`
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Title *string `json:"title"`
|
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
_ = json.Unmarshal(args, ¶ms)
|
_ = json.Unmarshal(args, ¶ms)
|
||||||
@@ -659,9 +654,6 @@ func (s *Server) mcpUpdateNote(ctx context.Context, userID string, args json.Raw
|
|||||||
if params.Body != nil {
|
if params.Body != nil {
|
||||||
n.Body = *params.Body
|
n.Body = *params.Body
|
||||||
}
|
}
|
||||||
if params.Title != nil {
|
|
||||||
n.Title = *params.Title
|
|
||||||
}
|
|
||||||
if params.Pinned != nil {
|
if params.Pinned != nil {
|
||||||
n.Pinned = *params.Pinned
|
n.Pinned = *params.Pinned
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ func (s *Server) handleListNotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
found, err := s.db.LooseNotes(r.Context(), user.ID)
|
// Everything they can see, not only the loose ones. Filing a note in a project
|
||||||
|
// is how it gets shared, and a list that dropped it at that moment made sharing
|
||||||
|
// look like deleting.
|
||||||
|
found, err := s.db.AllNotes(r.Context(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.internal(w, r, "list notes", err)
|
s.internal(w, r, "list notes", err)
|
||||||
return
|
return
|
||||||
@@ -94,9 +97,10 @@ func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, n)
|
writeJSON(w, http.StatusOK, n)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No title. It is the note's first line and is derived on every save — a field
|
||||||
|
// here would be a promise this server cannot keep.
|
||||||
type noteBody struct {
|
type noteBody struct {
|
||||||
ProjectID *string `json:"project_id"`
|
ProjectID *string `json:"project_id"`
|
||||||
Title *string `json:"title"`
|
|
||||||
Body *string `json:"body"`
|
Body *string `json:"body"`
|
||||||
Pinned *bool `json:"pinned"`
|
Pinned *bool `json:"pinned"`
|
||||||
}
|
}
|
||||||
@@ -116,9 +120,6 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
|
||||||
n.Title = *body.Title
|
|
||||||
}
|
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
@@ -161,9 +162,6 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
n.ProjectID = *body.ProjectID
|
n.ProjectID = *body.ProjectID
|
||||||
}
|
}
|
||||||
if body.Title != nil {
|
|
||||||
n.Title = *body.Title
|
|
||||||
}
|
|
||||||
if body.Body != nil {
|
if body.Body != nil {
|
||||||
n.Body = *body.Body
|
n.Body = *body.Body
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
|
|||||||
r.Get("/me", s.handleMe)
|
r.Get("/me", s.handleMe)
|
||||||
r.Patch("/me", s.handleUpdateProfile)
|
r.Patch("/me", s.handleUpdateProfile)
|
||||||
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
r.Put("/sidebar-sections", s.handleSetSidebarSections)
|
||||||
|
r.Put("/nav-order", s.handleSetNavOrder)
|
||||||
r.Post("/logout", s.handleLogout)
|
r.Post("/logout", s.handleLogout)
|
||||||
r.Post("/password/change", s.handleChangePassword)
|
r.Post("/password/change", s.handleChangePassword)
|
||||||
r.Post("/totp/setup", s.handleTOTPSetup)
|
r.Post("/totp/setup", s.handleTOTPSetup)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- The order of the fixed views in the sidebar.
|
||||||
|
--
|
||||||
|
-- On the account rather than in the browser, and for the reason the sidebar's
|
||||||
|
-- width is the other way round: a width is a fact about the screen you are at, and
|
||||||
|
-- the answer on a phone is not the answer on a monitor. Which order you want I dag,
|
||||||
|
-- Kommende, Venter and Indbakken in is a fact about you, and it should be the same
|
||||||
|
-- on the laptop as on the phone.
|
||||||
|
--
|
||||||
|
-- A list of keys rather than a number per row, for the same reason sidebar_collapsed
|
||||||
|
-- is: the set of views changes with the program, and a key that no longer exists
|
||||||
|
-- costs nothing — the sidebar draws what it recognises and appends anything new at
|
||||||
|
-- the end. That is what makes adding a view later not a migration.
|
||||||
|
ALTER TABLE users ADD COLUMN nav_order TEXT NOT NULL DEFAULT '[]';
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- #garageristeriet and #GarageRisteriet are the same project.
|
||||||
|
--
|
||||||
|
-- They were not, because the key was stored exactly as typed and looked up the
|
||||||
|
-- same way — so a note that named a project in lower case simply did not appear
|
||||||
|
-- on it, silently and with nothing to suggest why.
|
||||||
|
--
|
||||||
|
-- Folded on the way in from now on; this brings the rows already written into
|
||||||
|
-- line. Only projects: a [[note title]] is a title, and two notes called "Møde"
|
||||||
|
-- and "møde" are two notes.
|
||||||
|
--
|
||||||
|
-- The primary key is (note_id, kind, target_id), so folding can collide with a row
|
||||||
|
-- that is already there — a note that wrote both spellings. INSERT OR IGNORE into
|
||||||
|
-- a fresh set and swap, which keeps one of each rather than failing on the pair.
|
||||||
|
CREATE TABLE note_links_folded (
|
||||||
|
note_id TEXT NOT NULL REFERENCES notes (id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL CHECK (kind IN ('task', 'note', 'project')),
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (note_id, kind, target_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO note_links_folded (note_id, kind, target_id)
|
||||||
|
SELECT note_id, kind,
|
||||||
|
CASE WHEN kind = 'project' THEN lower(target_id) ELSE target_id END
|
||||||
|
FROM note_links;
|
||||||
|
|
||||||
|
DROP TABLE note_links;
|
||||||
|
ALTER TABLE note_links_folded RENAME TO note_links;
|
||||||
|
|
||||||
|
CREATE INDEX note_links_backwards ON note_links (kind, target_id);
|
||||||
+37
-6
@@ -62,11 +62,12 @@ func (db *DB) SaveNote(ctx context.Context, n *Note) error {
|
|||||||
}
|
}
|
||||||
n.UpdatedAt = now
|
n.UpdatedAt = now
|
||||||
|
|
||||||
// A note with no title takes its first line, the way Apple Notes does. A list
|
// The title is always the first line, the way Apple Notes does it. Not "when it
|
||||||
// of untitled notes is a list nobody can read.
|
// is empty": derived once and then left alone, it goes stale the moment somebody
|
||||||
if strings.TrimSpace(n.Title) == "" {
|
// rewrites the opening of a note, and the list ends up calling a note by a name
|
||||||
n.Title = firstLine(n.Body)
|
// that is nowhere in it. The column is a cache of the body, not a field beside
|
||||||
}
|
// it, and every caller gets the same answer because none of them can set it.
|
||||||
|
n.Title = firstLine(n.Body)
|
||||||
|
|
||||||
return db.Tx(ctx, func(tx *sql.Tx) error {
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
||||||
var projectID any
|
var projectID any
|
||||||
@@ -127,7 +128,14 @@ func LinksIn(body string) []NoteLink {
|
|||||||
add("task", strings.ToLower(m[1]))
|
add("task", strings.ToLower(m[1]))
|
||||||
}
|
}
|
||||||
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
|
for _, m := range projectRef.FindAllStringSubmatch(body, -1) {
|
||||||
add("project", m[1])
|
// Folded, because #garageristeriet and #GarageRisteriet are the same project
|
||||||
|
// to everybody except a database. The key is stored folded and looked up
|
||||||
|
// folded, so the comparison stays an exact match and keeps using its index —
|
||||||
|
// a comparison that lowered the column instead would read the whole table.
|
||||||
|
//
|
||||||
|
// The project's real spelling is not lost: it is on the project, which is
|
||||||
|
// what the interface shows. This column is a key, not a label.
|
||||||
|
add("project", strings.ToLower(m[1]))
|
||||||
}
|
}
|
||||||
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
for _, m := range noteRef.FindAllStringSubmatch(body, -1) {
|
||||||
add("note", strings.TrimSpace(m[1]))
|
add("note", strings.TrimSpace(m[1]))
|
||||||
@@ -184,11 +192,34 @@ func (db *DB) LooseNotes(ctx context.Context, userID string) ([]Note, error) {
|
|||||||
ORDER BY pinned DESC, updated_at DESC`, userID)
|
ORDER BY pinned DESC, updated_at DESC`, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllNotes is everything a person can see: their own loose ones, and the ones in
|
||||||
|
// projects they are part of.
|
||||||
|
//
|
||||||
|
// This is what the Notes page lists. Filing a note in a project is how it gets
|
||||||
|
// shared, and a list that dropped it at that moment would make sharing look like
|
||||||
|
// deleting — which is exactly how it read before this existed.
|
||||||
|
func (db *DB) AllNotes(ctx context.Context, userID string) ([]Note, error) {
|
||||||
|
return db.notesWhere(ctx, `
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND (
|
||||||
|
(project_id IS NULL AND created_by = ?)
|
||||||
|
OR project_id IN (
|
||||||
|
SELECT id FROM projects WHERE owner_id = ? AND deleted_at IS NULL
|
||||||
|
UNION
|
||||||
|
SELECT project_id FROM project_members WHERE user_id = ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ORDER BY pinned DESC, updated_at DESC`, userID, userID, userID)
|
||||||
|
}
|
||||||
|
|
||||||
// NotesLinking answers the backwards question: what points at this thing.
|
// NotesLinking answers the backwards question: what points at this thing.
|
||||||
//
|
//
|
||||||
// The reason note_links exists. Without it this would mean reading every note's
|
// The reason note_links exists. Without it this would mean reading every note's
|
||||||
// prose, and a panel that slow is one nobody leaves open.
|
// prose, and a panel that slow is one nobody leaves open.
|
||||||
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
func (db *DB) NotesLinking(ctx context.Context, kind, targetID string) ([]Note, error) {
|
||||||
|
if kind == "project" {
|
||||||
|
targetID = strings.ToLower(targetID)
|
||||||
|
}
|
||||||
return db.notesWhere(ctx, `
|
return db.notesWhere(ctx, `
|
||||||
WHERE deleted_at IS NULL
|
WHERE deleted_at IS NULL
|
||||||
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
AND id IN (SELECT note_id FROM note_links WHERE kind = ? AND target_id = ?)
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ func TestATagInANoteIsTheSameTagAsInATask(t *testing.T) {
|
|||||||
if len(projects) != 2 {
|
if len(projects) != 2 {
|
||||||
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
t.Fatalf("read %v out of the note, want the two projects", n.Links)
|
||||||
}
|
}
|
||||||
if projects[0] != "Firma" || projects[1] != "Regnskab" {
|
// Folded: the key is a key, not a label. #firma and #Firma are the same project
|
||||||
|
// to everybody except a database, and the project's own spelling is on the
|
||||||
|
// project, which is what the interface shows.
|
||||||
|
if projects[0] != "firma" || projects[1] != "regnskab" {
|
||||||
t.Errorf("got %v", projects)
|
t.Errorf("got %v", projects)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,10 +121,10 @@ func TestNotesAreFoundInEitherSpelling(t *testing.T) {
|
|||||||
db, userID := sealedStore(t)
|
db, userID := sealedStore(t)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// The title is the first line now, so that is where it goes.
|
||||||
if err := db.SaveNote(ctx, &Note{
|
if err := db.SaveNote(ctx, &Note{
|
||||||
CreatedBy: userID,
|
CreatedBy: userID,
|
||||||
Title: "Grønt regnskab",
|
Body: "Grønt regnskab\n\nMålt på årsbasis for Århus.",
|
||||||
Body: "Målt på årsbasis for Århus.",
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -173,7 +176,9 @@ func TestWhatIsReadOutOfABody(t *testing.T) {
|
|||||||
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
{"a hash inside a word is not a tag", "C#Sharp og x#y", nil},
|
||||||
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
{"a note by title", "se [[Møde med Anders]]", []NoteLink{{"note", "Møde med Anders"}}},
|
||||||
{"the same tag twice is one link", "#Firma og #Firma igen",
|
{"the same tag twice is one link", "#Firma og #Firma igen",
|
||||||
[]NoteLink{{"project", "Firma"}}},
|
[]NoteLink{{"project", "firma"}}},
|
||||||
|
{"two spellings are one tag", "#Firma og #firma",
|
||||||
|
[]NoteLink{{"project", "firma"}}},
|
||||||
{"a heading is not a tag", "# Overskrift", nil},
|
{"a heading is not a tag", "# Overskrift", nil},
|
||||||
} {
|
} {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -245,3 +250,86 @@ func TestTheProjectCountIsWhatIsLeft(t *testing.T) {
|
|||||||
t.Errorf("after deleting one, the count is %d, want 1", n)
|
t.Errorf("after deleting one, the count is %d, want 1", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The title follows the first line, always — not only the first time.
|
||||||
|
//
|
||||||
|
// Derived once and then left alone, it goes stale the moment somebody rewrites
|
||||||
|
// the opening, and the list ends up calling a note by a name that is nowhere in
|
||||||
|
// it. That is exactly what happened: a note headed "Ny note" was listed as
|
||||||
|
// "fdgfgfgh", which was what its first line had been some edits ago.
|
||||||
|
func TestTheTitleFollowsTheFirstLine(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
n := &Note{CreatedBy: userID, Body: "Første udgave\n\nnoget tekst"}
|
||||||
|
if err := db.SaveNote(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n.Title != "Første udgave" {
|
||||||
|
t.Fatalf("title started as %q", n.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
n.Body = "# Ny note\n\nDette er mit nye noteprogram"
|
||||||
|
if err := db.SaveNote(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n.Title != "Ny note" {
|
||||||
|
t.Errorf("after rewriting the first line the title is %q", n.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
back, err := db.Note(ctx, n.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if back.Title != "Ny note" {
|
||||||
|
t.Errorf("the stored title is %q", back.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And a title cannot be set beside the text, because then the two can disagree.
|
||||||
|
n.Title = "noget andet"
|
||||||
|
if err := db.SaveNote(ctx, n); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n.Title != "Ny note" {
|
||||||
|
t.Errorf("a title set by hand survived as %q", n.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note that names a project in the other case must still turn up on it.
|
||||||
|
//
|
||||||
|
// It did not, and nothing said why: the key was stored exactly as typed and
|
||||||
|
// looked up the same way, so #garageristeriet and #GarageRisteriet were two
|
||||||
|
// different things that looked identical to a person.
|
||||||
|
func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||||
|
db, userID := sealedStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if err := db.SaveNote(ctx, &Note{
|
||||||
|
CreatedBy: userID,
|
||||||
|
Body: "Bønnerne kommer fredag #garageristeriet",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, spelling := range []string{"GarageRisteriet", "garageristeriet", "GARAGERISTERIET"} {
|
||||||
|
found, err := db.NotesLinking(ctx, "project", spelling)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(found) != 1 {
|
||||||
|
t.Errorf("looking up %q found %d notes, want 1", spelling, len(found))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note title is a title, not a key: two notes called "Møde" and "møde" are
|
||||||
|
// two notes, and folding them together would merge things somebody kept apart.
|
||||||
|
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: "se [[Møde]]"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if found, _ := db.NotesLinking(ctx, "note", "møde"); len(found) != 0 {
|
||||||
|
t.Error("a note title was folded")
|
||||||
|
}
|
||||||
|
if found, _ := db.NotesLinking(ctx, "note", "Møde"); len(found) != 1 {
|
||||||
|
t.Error("a note title did not match itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+33
-5
@@ -36,8 +36,13 @@ type User struct {
|
|||||||
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
// is as true on the laptop as on the desktop. The sidebar's *width* is the
|
||||||
// opposite case and is stored per browser.
|
// opposite case and is stored per browser.
|
||||||
SidebarCollapsed []string
|
SidebarCollapsed []string
|
||||||
CreatedAt time.Time
|
|
||||||
UpdatedAt time.Time
|
// NavOrder is the order this person wants the fixed views in, by key. Empty
|
||||||
|
// means the order the program ships with. Unknown keys are ignored and missing
|
||||||
|
// ones are appended, so adding a view later is not a migration.
|
||||||
|
NavOrder []string
|
||||||
|
CreatedAt time.Time
|
||||||
|
UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
||||||
@@ -104,7 +109,7 @@ func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
||||||
avatar_color, timezone, locale, is_admin, sidebar_collapsed, created_at, updated_at`
|
avatar_color, timezone, locale, is_admin, sidebar_collapsed, nav_order, created_at, updated_at`
|
||||||
|
|
||||||
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
||||||
var u User
|
var u User
|
||||||
@@ -112,11 +117,11 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
var totpEnabled, isAdmin int
|
var totpEnabled, isAdmin int
|
||||||
var created, updated int64
|
var created, updated int64
|
||||||
|
|
||||||
var collapsed string
|
var collapsed, navOrder string
|
||||||
|
|
||||||
err := db.QueryRowContext(ctx, query, args...).Scan(
|
err := db.QueryRowContext(ctx, query, args...).Scan(
|
||||||
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
||||||
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &created, &updated)
|
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &collapsed, &navOrder, &created, &updated)
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -131,11 +136,34 @@ func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, e
|
|||||||
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
// worst case is a sidebar that opens fully, which is the state it shipped in.
|
||||||
u.SidebarCollapsed = []string{}
|
u.SidebarCollapsed = []string{}
|
||||||
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
_ = json.Unmarshal([]byte(collapsed), &u.SidebarCollapsed)
|
||||||
|
u.NavOrder = []string{}
|
||||||
|
_ = json.Unmarshal([]byte(navOrder), &u.NavOrder)
|
||||||
u.CreatedAt = time.Unix(created, 0).UTC()
|
u.CreatedAt = time.Unix(created, 0).UTC()
|
||||||
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
||||||
return &u, nil
|
return &u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetNavOrder records the order of the fixed views.
|
||||||
|
func (db *DB) SetNavOrder(ctx context.Context, userID string, order []string) error {
|
||||||
|
if order == nil {
|
||||||
|
order = []string{}
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
res, err := db.ExecContext(ctx,
|
||||||
|
`UPDATE users SET nav_order = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
string(raw), time.Now().Unix(), userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n, _ := res.RowsAffected(); n == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetSidebarCollapsed records which sidebar headings are folded.
|
// SetSidebarCollapsed records which sidebar headings are folded.
|
||||||
//
|
//
|
||||||
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
// Its own method rather than a field on UpdateProfile: that one backs a form with
|
||||||
|
|||||||
+247
-36
@@ -1875,6 +1875,13 @@ test('tallet ved et projekt er opgaver, og sidebjælken kan foldes med tastature
|
|||||||
await box.fill('første i dag');
|
await box.fill('første i dag');
|
||||||
await box.press('Enter');
|
await box.press('Enter');
|
||||||
|
|
||||||
|
// In the project itself before looking at the sidebar. Without this the count
|
||||||
|
// can be read while the task is still on its way, or — worse — after it landed
|
||||||
|
// in the inbox because the box that took the text belonged to the previous
|
||||||
|
// view. Then the assertion below fails for a reason that has nothing to do with
|
||||||
|
// what it is testing.
|
||||||
|
await expect(page.getByText('første', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
// Reloaded before looking. What is asserted is that the number is open tasks
|
// Reloaded before looking. What is asserted is that the number is open tasks
|
||||||
// and comes from the server, which holds on any load.
|
// and comes from the server, which holds on any load.
|
||||||
//
|
//
|
||||||
@@ -1955,48 +1962,252 @@ test('en note kan skrives, findes igen, og peger på det den nævner', async ({
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('noteeditoren viser formatering, og lagene flugter', async ({ page }) => {
|
test('noteeditoren er rich text, og teksten overlever turen til Markdown og tilbage', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
const trouble = watchForTrouble(page);
|
const trouble = watchForTrouble(page);
|
||||||
await page.goto('/noter');
|
await page.goto('/noter');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Ny note' }).click();
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
const body = page.getByLabel('Notens tekst');
|
const page_ = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
await body.fill(
|
await page_.click();
|
||||||
'# Overskrift\n\nDette er **fed** og *kursiv* og `kode`.\n\nHandler om #Firma og [[En anden note]].\n- et punkt\n> et citat'
|
|
||||||
);
|
|
||||||
|
|
||||||
// The marks are still there — this is a mirror, not a renderer — but they are
|
// Typed and formatted the way somebody would: pick a style, write, pick another.
|
||||||
// drawn as what they mean.
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
const mirror = page.locator('.mirror');
|
await page.getByRole('menuitem', { name: 'Titel' }).click();
|
||||||
await expect(mirror.locator('.bold')).toHaveText('**fed**');
|
await page.keyboard.type('Møde med Anders');
|
||||||
await expect(mirror.locator('.italic')).toHaveText('*kursiv*');
|
await page.keyboard.press('Enter');
|
||||||
await expect(mirror.locator('.code')).toHaveText('`kode`');
|
|
||||||
await expect(mirror.locator('.tag')).toHaveText('#Firma');
|
|
||||||
await expect(mirror.locator('.wikilink')).toHaveText('[[En anden note]]');
|
|
||||||
await expect(mirror.locator('.h1')).toContainText('Overskrift');
|
|
||||||
|
|
||||||
// The whole risk of the technique: two layers that must line up character for
|
await page.getByRole('button', { name: 'Formatér' }).click();
|
||||||
// character. If they drift, the formatting slides away from the text as the
|
await page.getByRole('menuitem', { name: 'Brødtekst' }).click();
|
||||||
// note grows, and it is unusable — so this measures rather than trusts.
|
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
|
||||||
const drift = await page.evaluate(() => {
|
|
||||||
const ta = document.querySelector('.paper textarea');
|
// The styles are real elements, not asterisks on screen. This is the whole of
|
||||||
const mi = document.querySelector('.paper .mirror');
|
// "it should feel like Apple Notes".
|
||||||
const a = getComputedStyle(ta), b = getComputedStyle(mi);
|
await expect(page_.locator('h1')).toHaveText('Møde med Anders');
|
||||||
return {
|
await expect(page_.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
|
||||||
font: a.fontSize === b.fontSize && a.fontFamily === b.fontFamily,
|
|
||||||
line: a.lineHeight === b.lineHeight,
|
// Bold through the toolbar, on a selection made with the keyboard. Not
|
||||||
wrap: a.whiteSpace === b.whiteSpace,
|
// Shift+Home — in a contenteditable that is the start of the document, so it
|
||||||
padding: a.paddingTop === b.paddingTop && a.paddingLeft === b.paddingLeft,
|
// took the heading with it and the button toggled bold off instead of on. Not a
|
||||||
// And the rendered height, which is what actually proves it: the same text
|
// double-click either: it leaves the selection empty here.
|
||||||
// laid out twice has to take the same room.
|
for (let i = 0; i < 5; i++) await page.keyboard.press('Shift+ArrowLeft');
|
||||||
heights: Math.abs(ta.scrollHeight - mi.scrollHeight)
|
await page.getByRole('button', { name: 'Fed' }).click();
|
||||||
};
|
// Either tag: the browser's own bold is <b>, the converter writes <strong>, and
|
||||||
});
|
// both are read as bold on the way back. Asserting one of them would be
|
||||||
expect(drift.font, 'skrifttypen er ikke ens').toBe(true);
|
// asserting which browser this is.
|
||||||
expect(drift.line, 'linjehøjden er ikke ens').toBe(true);
|
await expect(page_.locator('b, strong')).toBeVisible();
|
||||||
expect(drift.wrap, 'ombrydningen er ikke ens').toBe(true);
|
|
||||||
expect(drift.padding, 'polstringen er ikke ens').toBe(true);
|
// And the round trip, which is the thing that can quietly ruin a note: what is
|
||||||
expect(drift.heights, 'lagene fylder ikke det samme').toBeLessThanOrEqual(2);
|
// stored is Markdown, and reopening it has to give back the same document. A
|
||||||
|
// converter that disagrees with itself reshapes a note a little on every save.
|
||||||
|
await page_.blur();
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Møde med Anders/ }).click();
|
||||||
|
|
||||||
|
const back = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(back.locator('h1')).toHaveText('Møde med Anders');
|
||||||
|
await expect(back.locator('b, strong')).toBeVisible();
|
||||||
|
await expect(back).toContainText('kaffe hver uge');
|
||||||
|
// No stray Markdown on screen: if the marks are showing, the conversion failed
|
||||||
|
// in the direction nobody notices until a note looks wrong.
|
||||||
|
await expect(back).not.toContainText('**');
|
||||||
|
await expect(back).not.toContainText('# ');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en note kan deles med et projekts folk, og tages tilbage igen', async ({ page, browser }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Delenoter');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Delenoter' })).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).click();
|
||||||
|
await page.keyboard.type('Fælles aftale om leveringen');
|
||||||
|
await page.getByRole('textbox', { name: 'Notens tekst' }).blur();
|
||||||
|
|
||||||
|
// Shared by being filed in a project. Not an access list of its own.
|
||||||
|
await page.getByLabel('Del i').selectOption({ label: 'Delenoter' });
|
||||||
|
await expect(page.getByText('delt med projektets folk')).toBeVisible();
|
||||||
|
|
||||||
|
// And it is really there, not merely labelled: the project's own page shows it.
|
||||||
|
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||||
|
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Taken back out, it is the author's alone again.
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: /Fælles aftale/ }).click();
|
||||||
|
await page.getByLabel('Del i').selectOption({ label: 'Kun mig' });
|
||||||
|
await expect(page.getByText('din igen')).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'Delenoter' }).click();
|
||||||
|
await expect(page.locator('.notes').getByRole('link', { name: /Fælles aftale/ })).toBeHidden();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('udseendet kan skiftes uafhængigt af temaet, og overlever en genindlæsning', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/indstillinger');
|
||||||
|
|
||||||
|
const face = () =>
|
||||||
|
page.evaluate(() => getComputedStyle(document.body).fontFamily);
|
||||||
|
const ground = () =>
|
||||||
|
page.evaluate(() => getComputedStyle(document.body).backgroundColor);
|
||||||
|
|
||||||
|
const startFace = await face();
|
||||||
|
await page.getByRole('button', { name: /^Rolig/ }).click();
|
||||||
|
const serif = await face();
|
||||||
|
expect(serif, 'skriften skiftede ikke').not.toBe(startFace);
|
||||||
|
expect(serif.toLowerCase()).toContain('serif');
|
||||||
|
|
||||||
|
// The other axis is untouched: choosing a face must not drag the palette with
|
||||||
|
// it. That is the whole reason they are two settings and not one list.
|
||||||
|
const darkGround = await ground();
|
||||||
|
await page.getByRole('button', { name: /^Papir/ }).click();
|
||||||
|
expect(await ground(), 'temaet skiftede ikke').not.toBe(darkGround);
|
||||||
|
expect(await face(), 'temaet ændrede skriften').toBe(serif);
|
||||||
|
|
||||||
|
// And it is on before the first paint, not applied afterwards — otherwise the
|
||||||
|
// page is laid out once in one face and again in another.
|
||||||
|
await page.reload();
|
||||||
|
expect(await face()).toBe(serif);
|
||||||
|
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBe('rolig');
|
||||||
|
|
||||||
|
// Back to the default, which carries no attribute at all.
|
||||||
|
await page.getByRole('button', { name: /^Verdande/ }).click();
|
||||||
|
expect(await page.evaluate(() => document.documentElement.dataset.look)).toBeUndefined();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('en ny note begynder som titel, og linjen efter er brødtekst', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/noter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
|
||||||
|
// Straight in, without choosing a style. The first line is the title because
|
||||||
|
// that is what the list calls the note by.
|
||||||
|
await page.keyboard.type('Titelprøven');
|
||||||
|
await expect(ed.locator('h1')).toHaveText('Titelprøven');
|
||||||
|
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Han vil gerne have kaffe hver uge.');
|
||||||
|
|
||||||
|
// And the line after is body, not a second title. A note where every line is a
|
||||||
|
// heading is a note with no heading at all.
|
||||||
|
await expect(ed.locator('h1')).toHaveCount(1);
|
||||||
|
await expect(ed.locator('p').filter({ hasText: 'kaffe' })).toBeVisible();
|
||||||
|
|
||||||
|
// A third line stays body too.
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Og han ringer selv.');
|
||||||
|
await expect(ed.locator('h1')).toHaveCount(1);
|
||||||
|
|
||||||
|
// It survives the trip through Markdown: the title is a heading again on the
|
||||||
|
// way back, and the body is not.
|
||||||
|
await ed.blur();
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /Titelprøven/ }).click();
|
||||||
|
|
||||||
|
const back = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await expect(back.locator('h1')).toHaveText('Titelprøven');
|
||||||
|
await expect(back.locator('h1')).toHaveCount(1);
|
||||||
|
await expect(back).toContainText('ringer selv');
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('# foreslår et projekt, og titlen følger den første linje', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Forslagsprojektet');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'Forslagsprojektet' })).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
|
||||||
|
await page.keyboard.type('Første udkast');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
|
||||||
|
// Half a tag is enough. The name is long and nobody should have to spell it.
|
||||||
|
await page.keyboard.type('Handler om #Forslags');
|
||||||
|
const option = page.getByRole('option', { name: /Forslagsprojektet/ }).first();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
|
||||||
|
// Return takes the highlighted one — the list owns the key while it is open.
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await expect(ed).toContainText('#Forslagsprojektet');
|
||||||
|
await expect(option).toBeHidden();
|
||||||
|
|
||||||
|
// And the syntax is spelled out under the field, where it is being typed.
|
||||||
|
await expect(page.getByText('projekt', { exact: false }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// The title is the first line. It used to be derived once and then left alone,
|
||||||
|
// so rewriting the opening left the list calling the note by an old name.
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await ed.blur();
|
||||||
|
await expect(page.getByRole('button', { name: /Første udkast/ })).toBeVisible();
|
||||||
|
|
||||||
|
// Rewrite the first line; the list has to follow.
|
||||||
|
await ed.click();
|
||||||
|
await page.keyboard.press('ControlOrMeta+Home');
|
||||||
|
for (let i = 0; i < 13; i++) await page.keyboard.press('Shift+ArrowRight');
|
||||||
|
await page.keyboard.type('Andet udkast');
|
||||||
|
await ed.blur();
|
||||||
|
await expect(page.getByRole('button', { name: /Andet udkast/ })).toBeVisible();
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('et tag finder sit projekt uanset store og små bogstaver', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('StoreBogstaver');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(sidebar.getByRole('link', { name: 'StoreBogstaver' })).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'Noter', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Ny note' }).click();
|
||||||
|
const ed = page.getByRole('textbox', { name: 'Notens tekst' });
|
||||||
|
await ed.click();
|
||||||
|
|
||||||
|
// Written in lower case, the way somebody types when they are not thinking
|
||||||
|
// about it. It used to mean the note simply never appeared on the project, with
|
||||||
|
// nothing anywhere to say why.
|
||||||
|
await page.keyboard.type('Aftale om levering');
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.keyboard.type('Handler om #storebogstaver');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await ed.blur();
|
||||||
|
|
||||||
|
// The panel shows the project's own spelling, not the folded key: a label
|
||||||
|
// nobody wrote and that is nowhere in the note would be worse than none.
|
||||||
|
await expect(page.locator('.link', { hasText: '#StoreBogstaver' })).toBeVisible();
|
||||||
|
|
||||||
|
await sidebar.getByRole('link', { name: 'StoreBogstaver' }).click();
|
||||||
|
await expect(page.locator('.notes').getByRole('link', { name: /Aftale om levering/ })).toBeVisible();
|
||||||
|
|
||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -444,3 +444,72 @@ button {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- looks -----------------------------------------------------------------
|
||||||
|
*
|
||||||
|
* A second axis, beside the colour themes.
|
||||||
|
*
|
||||||
|
* A theme answers "how bright"; a look answers "what does it feel like to read".
|
||||||
|
* Typeface, how tight the rows sit, how round the corners are. They are separate
|
||||||
|
* because they are separate questions: somebody who wants a serif face at night
|
||||||
|
* should not have to give up the dark palette to get it.
|
||||||
|
*
|
||||||
|
* It is also the honest answer to looking like something else. The default is
|
||||||
|
* deliberately familiar — that is what was asked for — but a program with one
|
||||||
|
* possible appearance has borrowed a shape it cannot give back. With looks, the
|
||||||
|
* resemblance is one setting among several rather than the only thing it can be.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Serif, roomier, softer. For reading and writing rather than scanning — which is
|
||||||
|
what a program with notes in it is half the time. */
|
||||||
|
[data-look='rolig'] {
|
||||||
|
--font: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Times New Roman', serif;
|
||||||
|
--text-base: 1.0625rem;
|
||||||
|
--text-sm: 0.9375rem;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--s2: 0.5625rem;
|
||||||
|
--s3: 0.875rem;
|
||||||
|
--s4: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-look='rolig'] body {
|
||||||
|
line-height: 1.65;
|
||||||
|
letter-spacing: 0.006em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tighter. More rows on a screen, squarer corners, less air — for a long list
|
||||||
|
read at a glance rather than a page read a paragraph at a time. */
|
||||||
|
[data-look='taet'] {
|
||||||
|
--text-base: 0.875rem;
|
||||||
|
--text-sm: 0.78125rem;
|
||||||
|
--text-xs: 0.6875rem;
|
||||||
|
--radius: 5px;
|
||||||
|
--radius-lg: 8px;
|
||||||
|
--radius-sm: 3px;
|
||||||
|
--s1: 0.1875rem;
|
||||||
|
--s2: 0.375rem;
|
||||||
|
--s3: 0.5625rem;
|
||||||
|
--s4: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-look='taet'] body {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One face for everything, including the prose. Not a joke setting: a monospaced
|
||||||
|
list of tasks lines up in a way a proportional one never does, and some people
|
||||||
|
read code all day and want the same shapes here. */
|
||||||
|
[data-look='terminal'] {
|
||||||
|
--font: var(--font-mono);
|
||||||
|
--text-base: 0.875rem;
|
||||||
|
--text-sm: 0.8125rem;
|
||||||
|
--radius: 3px;
|
||||||
|
--radius-lg: 4px;
|
||||||
|
--radius-sm: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-look='terminal'] body {
|
||||||
|
line-height: 1.5;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
stored ??
|
stored ??
|
||||||
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
(window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
|
||||||
document.documentElement.dataset.theme = theme;
|
document.documentElement.dataset.theme = theme;
|
||||||
|
|
||||||
|
// The look changes the typeface and the spacing, so it has to be on
|
||||||
|
// before first paint too — otherwise the page is laid out once in one
|
||||||
|
// face and again in another, which is a visible jump.
|
||||||
|
const look = localStorage.getItem('verdande:look');
|
||||||
|
if (look && look !== 'verdande') document.documentElement.dataset.look = look;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Private browsing can refuse localStorage; the dark default stands.
|
// Private browsing can refuse localStorage; the dark default stands.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ export const api = {
|
|||||||
updateProfile: (data) => patch('/auth/me', data),
|
updateProfile: (data) => patch('/auth/me', data),
|
||||||
// Its own route rather than a field on the profile: that one backs a form with
|
// Its own route rather than a field on the profile: that one backs a form with
|
||||||
// a save button, and this writes on every click of a chevron.
|
// a save button, and this writes on every click of a chevron.
|
||||||
|
setNavOrder: (order) => put('/auth/nav-order', { order }),
|
||||||
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
|
||||||
signup: (data) => post('/auth/signup', data),
|
signup: (data) => post('/auth/signup', data),
|
||||||
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
forgotPassword: (email) => post('/auth/password/forgot', { email }),
|
||||||
|
|||||||
@@ -0,0 +1,585 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* The note itself.
|
||||||
|
*
|
||||||
|
* Rich text, not Markdown source: somebody who writes in Apple Notes presses
|
||||||
|
* ⌘B and expects the word to go bold, not to grow asterisks. The file underneath
|
||||||
|
* is still Markdown — see $lib/richtext.js for the seam — so nothing about the
|
||||||
|
* export changes.
|
||||||
|
*
|
||||||
|
* contenteditable with execCommand. It is deprecated and it is also the only
|
||||||
|
* thing every browser agrees on; writing a selection model from scratch is weeks
|
||||||
|
* of work and a new class of bug in the one place where losing text is
|
||||||
|
* unforgivable. When the replacement (the EditContext API) is everywhere, this
|
||||||
|
* is the file to change.
|
||||||
|
*/
|
||||||
|
import { markdownToHtml, htmlToMarkdown } from '$lib/richtext.js';
|
||||||
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
|
import { app } from '$lib/stores.svelte.js';
|
||||||
|
import { colorVar } from '$lib/colors.js';
|
||||||
|
|
||||||
|
let { note, onchange, onsave } = $props();
|
||||||
|
|
||||||
|
let editor;
|
||||||
|
let stylesOpen = $state(false);
|
||||||
|
// Which marks are on where the caret is, so the toolbar shows state rather than
|
||||||
|
// only offering actions.
|
||||||
|
let active = $state({});
|
||||||
|
|
||||||
|
// Loaded when the note changes, never while it is being typed in: writing to
|
||||||
|
// innerHTML puts the caret back at the start, which mid-sentence is unusable.
|
||||||
|
let loadedId = $state(null);
|
||||||
|
$effect(() => {
|
||||||
|
if (!editor || !note || note.id === loadedId) return;
|
||||||
|
// An empty note opens on its title. That is what Apple Notes does, and it is
|
||||||
|
// also what the list needs: the first line becomes the name of the note, so a
|
||||||
|
// note whose first line is body text is a note called by its first sentence.
|
||||||
|
//
|
||||||
|
// Tested on the text and not on the rendered result: an empty body renders as
|
||||||
|
// one empty paragraph, not as nothing, so a falsy check here never fired.
|
||||||
|
editor.innerHTML = note.body.trim() ? markdownToHtml(note.body) : '<h1><br></h1>';
|
||||||
|
loadedId = note.id;
|
||||||
|
active = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
const SYNTAX = [
|
||||||
|
{ mark: '#', what: 'notes.syntaxProject' },
|
||||||
|
{ mark: '[[', what: 'notes.syntaxNote' },
|
||||||
|
{ mark: '⌘B', what: 'notes.bold' },
|
||||||
|
{ mark: '⌘U', what: 'notes.underline' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const STYLES = [
|
||||||
|
{ key: 'title', tag: 'h1', label: 'notes.styleTitle' },
|
||||||
|
{ key: 'heading', tag: 'h2', label: 'notes.styleHeading' },
|
||||||
|
{ key: 'subheading', tag: 'h3', label: 'notes.styleSubheading' },
|
||||||
|
{ key: 'body', tag: 'p', label: 'notes.styleBody' },
|
||||||
|
{ key: 'mono', tag: 'pre', label: 'notes.styleMono' },
|
||||||
|
{ key: 'quote', tag: 'blockquote', label: 'notes.styleQuote' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function apply(command, value) {
|
||||||
|
editor?.focus();
|
||||||
|
// Tags, not inline styles. Left to itself the browser writes
|
||||||
|
// <span style="font-weight: normal"> for an un-bold, which is invisible to a
|
||||||
|
// converter that reads tags — so the change is lost on the way to Markdown
|
||||||
|
// and comes back bold. Asking for tags keeps the document in a shape that
|
||||||
|
// can be written down.
|
||||||
|
try {
|
||||||
|
document.execCommand('styleWithCSS', false, false);
|
||||||
|
} catch {
|
||||||
|
// Not everywhere. The formats below still work; some of them just produce
|
||||||
|
// spans, which is why the converter reads styles as well as tags.
|
||||||
|
}
|
||||||
|
document.execCommand(command, false, value);
|
||||||
|
readState();
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStyle(style) {
|
||||||
|
stylesOpen = false;
|
||||||
|
if (style.key === 'bullet') return apply('insertUnorderedList');
|
||||||
|
if (style.key === 'numbered') return apply('insertOrderedList');
|
||||||
|
apply('formatBlock', style.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the caret is standing in, for the toolbar's pressed states. */
|
||||||
|
function readState() {
|
||||||
|
if (!editor) return;
|
||||||
|
try {
|
||||||
|
active = {
|
||||||
|
bold: document.queryCommandState('bold'),
|
||||||
|
italic: document.queryCommandState('italic'),
|
||||||
|
underline: document.queryCommandState('underline'),
|
||||||
|
strike: document.queryCommandState('strikeThrough'),
|
||||||
|
bullet: document.queryCommandState('insertUnorderedList'),
|
||||||
|
numbered: document.queryCommandState('insertOrderedList')
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Some browsers throw on queryCommandState with no selection. The toolbar
|
||||||
|
// showing nothing is a smaller problem than the editor throwing.
|
||||||
|
active = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changed() {
|
||||||
|
if (!editor) return;
|
||||||
|
onchange?.(htmlToMarkdown(editor));
|
||||||
|
readSuggestions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- suggesting a project ------------------------------------------------------
|
||||||
|
|
||||||
|
let suggestions = $state([]);
|
||||||
|
let chosen = $state(0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The word being typed after a #, if that is what is happening.
|
||||||
|
*
|
||||||
|
* Read from the text node the caret is in rather than from the whole document:
|
||||||
|
* a note can mention twenty projects, and only the one under the cursor is being
|
||||||
|
* written.
|
||||||
|
*/
|
||||||
|
function partialTag() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const node = selection?.anchorNode;
|
||||||
|
if (!node || node.nodeType !== Node.TEXT_NODE || !selection.isCollapsed) return null;
|
||||||
|
|
||||||
|
const before = node.textContent.slice(0, selection.anchorOffset);
|
||||||
|
const match = /(?:^|\s)#([\p{L}\p{N}_-]*)$/u.exec(before);
|
||||||
|
if (!match) return null;
|
||||||
|
return { node, start: selection.anchorOffset - match[1].length, term: match[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSuggestions() {
|
||||||
|
const partial = partialTag();
|
||||||
|
if (!partial) {
|
||||||
|
suggestions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const term = partial.term.toLowerCase();
|
||||||
|
suggestions = app.projects
|
||||||
|
.filter((p) => !p.is_inbox && p.name.toLowerCase().includes(term))
|
||||||
|
.slice(0, 6);
|
||||||
|
chosen = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the half-typed tag with the whole name. */
|
||||||
|
function accept(project) {
|
||||||
|
const partial = partialTag();
|
||||||
|
if (!partial) return;
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(partial.node, partial.start);
|
||||||
|
range.setEnd(partial.node, partial.start + partial.term.length);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
|
||||||
|
// A trailing space, because the next thing typed is a word and not more of
|
||||||
|
// the tag — and without it the suggestions come straight back.
|
||||||
|
document.execCommand('insertText', false, project.name + ' ');
|
||||||
|
suggestions = [];
|
||||||
|
onchange?.(htmlToMarkdown(editor));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The block the caret is standing in. */
|
||||||
|
function currentBlock() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || !selection.anchorNode || !editor) return null;
|
||||||
|
let node = selection.anchorNode;
|
||||||
|
while (node && node.parentNode !== editor) node = node.parentNode;
|
||||||
|
return node?.nodeType === Node.ELEMENT_NODE ? node : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Title for the first line, body for everything after it.
|
||||||
|
*
|
||||||
|
* Run after the browser has split the block on Enter, because until then there
|
||||||
|
* is no second block to change. Pressing return at the end of a heading leaves
|
||||||
|
* the caret in another heading in most browsers, and a note where every line is
|
||||||
|
* a title is a note with no title at all.
|
||||||
|
*/
|
||||||
|
function keepTitleFirst() {
|
||||||
|
const block = currentBlock();
|
||||||
|
if (!block) return;
|
||||||
|
|
||||||
|
// A heading that is not the first line becomes body.
|
||||||
|
if (block.tagName === 'H1' && block !== editor.firstElementChild) {
|
||||||
|
document.execCommand('formatBlock', false, 'p');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the browser's own container becomes a paragraph. Chromium answers Enter
|
||||||
|
// with a bare <div>, which reads as body but is not styled as one — so the
|
||||||
|
// spacing between paragraphs quietly disappears the moment somebody presses
|
||||||
|
// return rather than choosing Brødtekst from the menu.
|
||||||
|
if (block.tagName === 'DIV') {
|
||||||
|
document.execCommand('formatBlock', false, 'p');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onkeydown(event) {
|
||||||
|
// The suggestion list owns the arrows and return while it is open, the way
|
||||||
|
// every other completion does. Escape closes it without choosing.
|
||||||
|
if (suggestions.length) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault();
|
||||||
|
chosen = (chosen + 1) % suggestions.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
chosen = (chosen - 1 + suggestions.length) % suggestions.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
|
event.preventDefault();
|
||||||
|
accept(suggestions[chosen]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
suggestions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && !event.shiftKey) {
|
||||||
|
// After the split, not before it.
|
||||||
|
setTimeout(() => {
|
||||||
|
keepTitleFirst();
|
||||||
|
changed();
|
||||||
|
}, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The shortcuts people already have in their fingers. execCommand handles
|
||||||
|
// bold and italic itself; underline and strikethrough are claimed here so
|
||||||
|
// they behave the same way.
|
||||||
|
if (!(event.metaKey || event.ctrlKey)) return;
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
if (key === 'u') {
|
||||||
|
event.preventDefault();
|
||||||
|
apply('underline');
|
||||||
|
} else if (key === 'x' && event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
apply('strikeThrough');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pasted text arrives as whatever the source was — a web page brings its fonts,
|
||||||
|
// its colours and its layout with it. Only the words are wanted.
|
||||||
|
function onpaste(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = event.clipboardData?.getData('text/plain') ?? '';
|
||||||
|
document.execCommand('insertText', false, text);
|
||||||
|
changed();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
<!-- Nothing in here may take focus. A click that moves focus out of the editor
|
||||||
|
collapses the selection first, so the format lands on nothing — the button
|
||||||
|
appears to do nothing at all, which is how it read the first time. -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="toolbar" onmousedown={(e) => e.preventDefault()}>
|
||||||
|
<div class="styles">
|
||||||
|
<button
|
||||||
|
class="aa"
|
||||||
|
onclick={() => (stylesOpen = !stylesOpen)}
|
||||||
|
aria-expanded={stylesOpen}
|
||||||
|
aria-label={t('notes.format')}
|
||||||
|
title={t('notes.format')}>Aa</button
|
||||||
|
>
|
||||||
|
{#if stylesOpen}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="menu" role="menu">
|
||||||
|
{#each STYLES as style}
|
||||||
|
<button class={style.key} role="menuitem" onclick={() => setStyle(style)}>
|
||||||
|
{t(style.label)}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<hr />
|
||||||
|
<button
|
||||||
|
class:on={active.bullet}
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => setStyle({ key: 'bullet' })}>• {t('notes.styleBullet')}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class:on={active.numbered}
|
||||||
|
role="menuitem"
|
||||||
|
onclick={() => setStyle({ key: 'numbered' })}>1. {t('notes.styleNumbered')}</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="sep" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<button class:on={active.bold} onclick={() => apply('bold')} title="⌘B" aria-label={t('notes.bold')}>
|
||||||
|
<strong>B</strong>
|
||||||
|
</button>
|
||||||
|
<button class:on={active.italic} onclick={() => apply('italic')} title="⌘I" aria-label={t('notes.italic')}>
|
||||||
|
<em>I</em>
|
||||||
|
</button>
|
||||||
|
<button class:on={active.underline} onclick={() => apply('underline')} title="⌘U" aria-label={t('notes.underline')}>
|
||||||
|
<u>U</u>
|
||||||
|
</button>
|
||||||
|
<button class:on={active.strike} onclick={() => apply('strikeThrough')} aria-label={t('notes.strike')}>
|
||||||
|
<s>S</s>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="page"
|
||||||
|
bind:this={editor}
|
||||||
|
contenteditable="true"
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label={t('notes.body')}
|
||||||
|
spellcheck="true"
|
||||||
|
oninput={changed}
|
||||||
|
onblur={onsave}
|
||||||
|
onkeyup={() => {
|
||||||
|
readState();
|
||||||
|
readSuggestions();
|
||||||
|
}}
|
||||||
|
onmouseup={readState}
|
||||||
|
{onkeydown}
|
||||||
|
{onpaste}
|
||||||
|
></div>
|
||||||
|
|
||||||
|
{#if suggestions.length}
|
||||||
|
<!-- Anchored to the editor rather than to the caret. Following the caret means
|
||||||
|
measuring it, and a list that jumps a few pixels as you type is worse than
|
||||||
|
one that sits still. -->
|
||||||
|
<ul class="suggestions" role="listbox">
|
||||||
|
{#each suggestions as project, i (project.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
class:on={i === chosen}
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === chosen}
|
||||||
|
onmousedown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
accept(project);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="dot" style="background: {colorVar(project.color)}" aria-hidden="true"
|
||||||
|
></span>
|
||||||
|
#{project.name}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- What the text understands, said where it is being typed. The same idea as
|
||||||
|
the line under the quick-add box: a note is the other place in this program
|
||||||
|
where what you type is read for meaning. -->
|
||||||
|
<p class="legend">
|
||||||
|
{#each SYNTAX as item}
|
||||||
|
<span><kbd>{item.mark}</kbd> {t(item.what)}</span>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s1);
|
||||||
|
padding: 0 0 var(--s2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar button.on {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--line);
|
||||||
|
margin: 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.styles {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aa {
|
||||||
|
font-weight: 560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--s1));
|
||||||
|
left: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 210px;
|
||||||
|
padding: var(--s1);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu button {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--s1) var(--s2);
|
||||||
|
height: auto;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin: var(--s1) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The menu shows each style in the style it applies, the way Apple Notes does.
|
||||||
|
Reading the word "Overskrift" set as one is faster than reading a label. */
|
||||||
|
.menu .title {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .heading {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 640;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .subheading {
|
||||||
|
font-weight: 620;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .mono {
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu .quote {
|
||||||
|
border-left: 2px solid var(--line-strong);
|
||||||
|
padding-left: var(--s2);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--s1);
|
||||||
|
min-width: 200px;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s2);
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--s1) var(--s2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.suggestions button.on {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--s1) var(--s3);
|
||||||
|
padding-top: var(--s2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend kbd {
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 50vh;
|
||||||
|
padding: var(--s3) 0;
|
||||||
|
outline: none;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The document styles. These are the whole of "it should look like Apple Notes":
|
||||||
|
a title that is a title, and body text that is comfortable to read for a page
|
||||||
|
at a time. */
|
||||||
|
.page :global(h1) {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(h2) {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 640;
|
||||||
|
margin: var(--s3) 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(h3) {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 620;
|
||||||
|
margin: var(--s3) 0 var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(p) {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(ul),
|
||||||
|
.page :global(ol) {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
padding-left: var(--s4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(li) {
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(blockquote) {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
padding-left: var(--s3);
|
||||||
|
border-left: 2px solid var(--line-strong);
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(pre) {
|
||||||
|
margin: 0 0 var(--s2);
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page :global(code) {
|
||||||
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -117,6 +117,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- the fixed views ---------------------------------------------------------
|
||||||
|
|
||||||
|
// Delegated is only offered where delegating is possible at all: on an instance
|
||||||
|
// with one person that view can never have anything in it, and a permanent empty
|
||||||
|
// entry is a question the sidebar keeps asking and answering "no".
|
||||||
|
let navItems = $derived(
|
||||||
|
[
|
||||||
|
{ key: 'today', href: '/', label: 'nav.today' },
|
||||||
|
{ key: 'upcoming', href: '/upcoming', label: 'nav.upcoming' },
|
||||||
|
app.projects.some((p) => p.shared)
|
||||||
|
? { key: 'delegated', href: '/uddelegeret', label: 'nav.delegated' }
|
||||||
|
: null,
|
||||||
|
// No href: the inbox is a project and is drawn by projectRow, which knows
|
||||||
|
// its id. It is in this list only so it can be dragged with the others.
|
||||||
|
{ key: 'inbox', label: 'nav.inbox' }
|
||||||
|
].filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
let orderedNav = $derived(
|
||||||
|
app
|
||||||
|
.navOrder(navItems.map((i) => i.key))
|
||||||
|
.map((key) => navItems.find((i) => i.key === key))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
let draggingNav = $state(null);
|
||||||
|
let overNav = $state(null);
|
||||||
|
|
||||||
|
function onNavDragStart(event, key) {
|
||||||
|
draggingNav = key;
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
// A payload, because Firefox refuses to start a drag without one.
|
||||||
|
event.dataTransfer.setData('text/plain', key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNavDragOver(event, key) {
|
||||||
|
if (!draggingNav || key === draggingNav) return;
|
||||||
|
event.preventDefault();
|
||||||
|
overNav = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearNavDrag() {
|
||||||
|
draggingNav = null;
|
||||||
|
overNav = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onNavDrop(event, key) {
|
||||||
|
const moved = draggingNav;
|
||||||
|
// Checked before anything is swallowed. Stopping the event first meant a task
|
||||||
|
// dragged onto I dag never reached the handler that gives it a date — the row
|
||||||
|
// took the drop and then discovered it was not for it.
|
||||||
|
if (!moved || moved === key) {
|
||||||
|
clearNavDrag();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
clearNavDrag();
|
||||||
|
|
||||||
|
const keys = orderedNav.map((i) => i.key).filter((k) => k !== moved);
|
||||||
|
keys.splice(keys.indexOf(key), 0, moved);
|
||||||
|
await app.setNavOrder(keys);
|
||||||
|
}
|
||||||
|
|
||||||
// --- resizing -------------------------------------------------------------------
|
// --- resizing -------------------------------------------------------------------
|
||||||
|
|
||||||
let resizing = $state(false);
|
let resizing = $state(false);
|
||||||
@@ -340,43 +404,60 @@
|
|||||||
|
|
||||||
<div class="views">
|
<div class="views">
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<a
|
<!-- The fixed views, in the order this person put them.
|
||||||
href="/"
|
Drawn from a list rather than written out, because they can be reordered
|
||||||
class:active={current === '/'}
|
now and a hand-written run cannot be. Unknown keys are dropped and new
|
||||||
class:receiving={overToday}
|
ones appended, so adding a view later does not strand anybody's order. -->
|
||||||
onclick={onnavigate}
|
{#each orderedNav as item (item.key)}
|
||||||
ondragover={(e) => {
|
{#if item.key === 'inbox'}
|
||||||
if (!carries(e, TASK)) return;
|
{#if app.inbox}
|
||||||
accept(e);
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
overToday = true;
|
<div
|
||||||
}}
|
class="navrow"
|
||||||
ondragleave={() => (overToday = false)}
|
class:dragging={draggingNav === item.key}
|
||||||
ondrop={onDropOnToday}
|
class:over={overNav === item.key}
|
||||||
>
|
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||||
<span class="dot today" aria-hidden="true"></span>
|
ondragleave={() => (overNav = null)}
|
||||||
{t('nav.today')}
|
ondrop={(e) => onNavDrop(e, item.key)}
|
||||||
</a>
|
>
|
||||||
<a href="/upcoming" class:active={current === '/upcoming'} onclick={onnavigate}>
|
{@render grip(item.key)}
|
||||||
<span class="dot" aria-hidden="true"></span>
|
{@render projectRow(app.inbox, false)}
|
||||||
{t('nav.upcoming')}
|
</div>
|
||||||
</a>
|
{/if}
|
||||||
{#if app.projects.some((p) => p.shared)}
|
{:else}
|
||||||
<!-- Only where delegating is possible at all. On an instance with one
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
person this view can never have anything in it, and a permanent empty
|
<div
|
||||||
entry is a question the sidebar keeps asking and answering "no". -->
|
class="navrow"
|
||||||
<a href="/uddelegeret" class:active={current === '/uddelegeret'} onclick={onnavigate}>
|
class:dragging={draggingNav === item.key}
|
||||||
<span class="dot" aria-hidden="true"></span>
|
class:over={overNav === item.key}
|
||||||
{t('nav.delegated')}
|
ondragover={(e) => onNavDragOver(e, item.key)}
|
||||||
</a>
|
ondragleave={() => (overNav = null)}
|
||||||
{/if}
|
ondrop={(e) => onNavDrop(e, item.key)}
|
||||||
<!-- Færdige is not in this list any more. It used to sit here on the argument
|
>
|
||||||
that somebody hunting a task they closed by mistake does not know which
|
{@render grip(item.key)}
|
||||||
view they closed it in — which is true, and is also a rare errand next to
|
<!-- I dag also takes a task dropped on it — the most-made rescheduling
|
||||||
four entries that are read every day. It lives under Settings → Data, with
|
there is, and the sidebar is where the pointer already is. The
|
||||||
the trash, where the other "where did that go" questions are answered. -->
|
handlers were lost when this run became a list; the test caught
|
||||||
{#if app.inbox}
|
it, which is the second time that drop has earned its test. -->
|
||||||
{@render projectRow(app.inbox, false)}
|
<a
|
||||||
{/if}
|
href={item.href}
|
||||||
|
class:active={current === item.href}
|
||||||
|
class:receiving={item.key === 'today' && overToday}
|
||||||
|
onclick={onnavigate}
|
||||||
|
ondragover={(e) => {
|
||||||
|
if (item.key !== 'today' || !carries(e, TASK)) return;
|
||||||
|
accept(e);
|
||||||
|
overToday = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (overToday = false)}
|
||||||
|
ondrop={(e) => item.key === 'today' && onDropOnToday(e)}
|
||||||
|
>
|
||||||
|
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||||
|
{t(item.label)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notes stand apart, with a rule above them.
|
<!-- Notes stand apart, with a rule above them.
|
||||||
@@ -395,6 +476,21 @@
|
|||||||
three copies of that would be three chances to fix a bug in two of them.
|
three copies of that would be three chances to fix a bug in two of them.
|
||||||
Only your own reorder: `sort_order` is a column on the project, so a shared
|
Only your own reorder: `sort_order` is a column on the project, so a shared
|
||||||
one stays where its owner put it. -->
|
one stays where its owner put it. -->
|
||||||
|
<!-- A handle rather than the whole row.
|
||||||
|
Making the row draggable made everything inside it draggable too, and the
|
||||||
|
sidebar is already a drop target for tasks — a task dragged onto I dag
|
||||||
|
stopped landing, because the row had claimed the gesture. A grip claims
|
||||||
|
nothing until it is grabbed. -->
|
||||||
|
{#snippet grip(key)}
|
||||||
|
<span
|
||||||
|
class="grip"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e) => onNavDragStart(e, key)}
|
||||||
|
ondragend={clearNavDrag}
|
||||||
|
aria-hidden="true">⠿</span
|
||||||
|
>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
{#snippet projectRow(project, sortable)}
|
{#snippet projectRow(project, sortable)}
|
||||||
<a
|
<a
|
||||||
href="/projekt/{project.id}"
|
href="/projekt/{project.id}"
|
||||||
@@ -871,6 +967,53 @@
|
|||||||
gap: 1px;
|
gap: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navrow {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Takes no room until it is wanted: it sits in the padding the row already has,
|
||||||
|
so nothing moves when it appears. */
|
||||||
|
.grip {
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow:hover .grip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow > :global(a) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navrow.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A line where it will land, not a box around what it will land on: the first
|
||||||
|
reads as a position and the second as a target. */
|
||||||
|
.navrow.over::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
left: var(--s2);
|
||||||
|
right: var(--s2);
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
/* A rule and a little air, which is the whole of "separate". Notes are the other
|
||||||
half of the program, not a fifth way of looking at the same tasks. */
|
half of the program, not a fifth way of looking at the same tasks. */
|
||||||
.apart {
|
.apart {
|
||||||
|
|||||||
@@ -16,6 +16,28 @@
|
|||||||
*/
|
*/
|
||||||
export const da = {
|
export const da = {
|
||||||
// --- the shell ---------------------------------------------------------------
|
// --- the shell ---------------------------------------------------------------
|
||||||
|
'notes.shareWith': 'Del i',
|
||||||
|
'notes.private': 'Kun mig',
|
||||||
|
'notes.shared': 'Noten er delt med projektets folk.',
|
||||||
|
'notes.unshared': 'Noten er din igen.',
|
||||||
|
'notes.save': 'Gem',
|
||||||
|
'notes.format': 'Formatér',
|
||||||
|
'notes.bold': 'Fed',
|
||||||
|
'notes.italic': 'Kursiv',
|
||||||
|
'notes.underline': 'Understreget',
|
||||||
|
'notes.strike': 'Gennemstreget',
|
||||||
|
'notes.styleTitle': 'Titel',
|
||||||
|
'notes.styleHeading': 'Overskrift',
|
||||||
|
'notes.styleSubheading': 'Underrubrik',
|
||||||
|
'notes.styleBody': 'Brødtekst',
|
||||||
|
'notes.styleMono': 'Monotype',
|
||||||
|
'notes.styleQuote': 'Blokcitat',
|
||||||
|
'notes.styleBullet': 'Punktliste',
|
||||||
|
'notes.styleNumbered': 'Nummereret liste',
|
||||||
|
'notes.favourite': 'Gør til favorit',
|
||||||
|
'notes.unfavourite': 'Fjern som favorit',
|
||||||
|
'notes.syntaxProject': 'projekt',
|
||||||
|
'notes.syntaxNote': 'en anden note',
|
||||||
'notes.title': 'Noter',
|
'notes.title': 'Noter',
|
||||||
'notes.new': 'Ny note',
|
'notes.new': 'Ny note',
|
||||||
'notes.search': 'Søg i noter',
|
'notes.search': 'Søg i noter',
|
||||||
@@ -124,6 +146,16 @@ export const da = {
|
|||||||
'account.appearance': 'Udseende',
|
'account.appearance': 'Udseende',
|
||||||
'account.appearanceHint':
|
'account.appearanceHint':
|
||||||
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
|
'Gemmes i denne browser. Det er en egenskab ved den skærm, du sidder ved — en bærbar i solen og en skærm i et mørkt rum vil have hvert sit svar.',
|
||||||
|
'account.look': 'Udseende',
|
||||||
|
'account.lookHint': 'Temaet siger hvor lyst. Det her siger hvordan det læses — skrift, luft og hjørner. De to kan kombineres frit.',
|
||||||
|
'look.verdande': 'Verdande',
|
||||||
|
'look.verdandeNote': 'Som du kender den.',
|
||||||
|
'look.rolig': 'Rolig',
|
||||||
|
'look.roligNote': 'Serif, mere luft. Til at læse og skrive i.',
|
||||||
|
'look.taet': 'Tæt',
|
||||||
|
'look.taetNote': 'Flere rækker på skærmen, mindre luft.',
|
||||||
|
'look.terminal': 'Terminal',
|
||||||
|
'look.terminalNote': 'Én bredde til det hele. Alt flugter.',
|
||||||
'account.password': 'Adgangskode',
|
'account.password': 'Adgangskode',
|
||||||
'account.passwordHint':
|
'account.passwordHint':
|
||||||
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
|
'At skifte den logger alle andre sessioner ud — hvilket for det meste er grunden til at gøre det.',
|
||||||
|
|||||||
@@ -12,6 +12,28 @@
|
|||||||
*/
|
*/
|
||||||
export const en = {
|
export const en = {
|
||||||
// --- the shell ---------------------------------------------------------------
|
// --- the shell ---------------------------------------------------------------
|
||||||
|
'notes.shareWith': 'Share in',
|
||||||
|
'notes.private': 'Only me',
|
||||||
|
'notes.shared': "The note is shared with the project's people.",
|
||||||
|
'notes.unshared': 'The note is yours again.',
|
||||||
|
'notes.save': 'Save',
|
||||||
|
'notes.format': 'Format',
|
||||||
|
'notes.bold': 'Bold',
|
||||||
|
'notes.italic': 'Italic',
|
||||||
|
'notes.underline': 'Underline',
|
||||||
|
'notes.strike': 'Strikethrough',
|
||||||
|
'notes.styleTitle': 'Title',
|
||||||
|
'notes.styleHeading': 'Heading',
|
||||||
|
'notes.styleSubheading': 'Subheading',
|
||||||
|
'notes.styleBody': 'Body',
|
||||||
|
'notes.styleMono': 'Monospaced',
|
||||||
|
'notes.styleQuote': 'Block quote',
|
||||||
|
'notes.styleBullet': 'Bulleted list',
|
||||||
|
'notes.styleNumbered': 'Numbered list',
|
||||||
|
'notes.favourite': 'Make a favourite',
|
||||||
|
'notes.unfavourite': 'Remove from favourites',
|
||||||
|
'notes.syntaxProject': 'project',
|
||||||
|
'notes.syntaxNote': 'another note',
|
||||||
'notes.title': 'Notes',
|
'notes.title': 'Notes',
|
||||||
'notes.new': 'New note',
|
'notes.new': 'New note',
|
||||||
'notes.search': 'Search notes',
|
'notes.search': 'Search notes',
|
||||||
@@ -120,6 +142,16 @@ export const en = {
|
|||||||
'account.appearance': 'Appearance',
|
'account.appearance': 'Appearance',
|
||||||
'account.appearanceHint':
|
'account.appearanceHint':
|
||||||
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
'Kept in this browser. It is a property of the screen you are sitting at — a laptop in the sun and a monitor in a dark room want different answers.',
|
||||||
|
'account.look': 'Appearance',
|
||||||
|
'account.lookHint': 'The theme says how bright. This says how it reads — typeface, air and corners. The two combine freely.',
|
||||||
|
'look.verdande': 'Verdande',
|
||||||
|
'look.verdandeNote': 'As you know it.',
|
||||||
|
'look.rolig': 'Calm',
|
||||||
|
'look.roligNote': 'Serif, more air. For reading and writing.',
|
||||||
|
'look.taet': 'Tight',
|
||||||
|
'look.taetNote': 'More rows on screen, less air.',
|
||||||
|
'look.terminal': 'Terminal',
|
||||||
|
'look.terminalNote': 'One width for everything. It all lines up.',
|
||||||
'account.password': 'Password',
|
'account.password': 'Password',
|
||||||
'account.passwordHint':
|
'account.passwordHint':
|
||||||
'Changing it signs every other session out — which is usually the reason to do it.',
|
'Changing it signs every other session out — which is usually the reason to do it.',
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
/**
|
|
||||||
* Just enough Markdown to see what you are writing.
|
|
||||||
*
|
|
||||||
* Not a renderer: the output is the same text, in the same order, with parts
|
|
||||||
* wrapped so they can be styled. That matters because it sits behind a textarea
|
|
||||||
* and has to line up with it character for character — a renderer that dropped
|
|
||||||
* the `**` or reflowed a list would drift out of alignment on the first bold word.
|
|
||||||
*
|
|
||||||
* So the marks stay, dimmed. Obsidian hides them except in the line the cursor is
|
|
||||||
* in; dimming them always is simpler, does not move text under the cursor as it
|
|
||||||
* travels, and still lets you read a document at a glance. It is a smaller idea
|
|
||||||
* that survives contact with a textarea.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const INLINE = [
|
|
||||||
// Order matters: the longer fence first, or `**bold**` is read as two italics.
|
|
||||||
{ kind: 'code', re: /`([^`\n]+)`/g },
|
|
||||||
{ kind: 'bold', re: /\*\*([^*\n]+)\*\*/g },
|
|
||||||
{ kind: 'italic', re: /\*([^*\n]+)\*/g },
|
|
||||||
{ kind: 'tag', re: /(?:^|(?<=\s))#[\p{L}\p{N}_-]{1,64}/gu },
|
|
||||||
{ kind: 'wikilink', re: /\[\[[^\]\n]{1,200}\]\]/g }
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* segmentsOf splits one line into pieces, each with the kind it should be drawn
|
|
||||||
* as. A piece with no kind is ordinary text.
|
|
||||||
*/
|
|
||||||
function inlineSegments(line) {
|
|
||||||
const marks = [];
|
|
||||||
|
|
||||||
// Each pass reads a copy with the earlier passes' characters blanked out.
|
|
||||||
//
|
|
||||||
// Skipping overlaps after the fact is not enough, and the failure is subtle:
|
|
||||||
// after `**fed**` is claimed as bold, a plain overlap check still lets the
|
|
||||||
// italic pass start at the leftover `*` and run to the next one — swallowing
|
|
||||||
// " og " and leaving the actual italic word unmarked. Blanking means a later
|
|
||||||
// pass cannot see the characters at all.
|
|
||||||
let masked = line;
|
|
||||||
const blank = (start, end) =>
|
|
||||||
(masked = masked.slice(0, start) + '\u0000'.repeat(end - start) + masked.slice(end));
|
|
||||||
|
|
||||||
for (const { kind, re } of INLINE) {
|
|
||||||
re.lastIndex = 0;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(masked))) {
|
|
||||||
marks.push({ kind, start: m.index, end: m.index + m[0].length });
|
|
||||||
}
|
|
||||||
for (const mark of marks) blank(mark.start, mark.end);
|
|
||||||
}
|
|
||||||
marks.sort((a, b) => a.start - b.start);
|
|
||||||
|
|
||||||
const out = [];
|
|
||||||
let at = 0;
|
|
||||||
for (const mark of marks) {
|
|
||||||
if (mark.start > at) out.push({ text: line.slice(at, mark.start) });
|
|
||||||
out.push({ text: line.slice(mark.start, mark.end), kind: mark.kind });
|
|
||||||
at = mark.end;
|
|
||||||
}
|
|
||||||
if (at < line.length) out.push({ text: line.slice(at) });
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* linesOf turns a body into lines, each with a block kind and its inline pieces.
|
|
||||||
* Every line is returned, blank ones included, or the mirror would be one line
|
|
||||||
* short of the textarea for every empty line in the note.
|
|
||||||
*/
|
|
||||||
export function linesOf(body) {
|
|
||||||
return body.split('\n').map((line) => {
|
|
||||||
const heading = /^(#{1,6})\s/.exec(line);
|
|
||||||
if (heading) {
|
|
||||||
return { block: `h${heading[1].length}`, parts: inlineSegments(line) };
|
|
||||||
}
|
|
||||||
if (/^\s*[-*+]\s/.test(line)) return { block: 'list', parts: inlineSegments(line) };
|
|
||||||
if (/^\s*\d+\.\s/.test(line)) return { block: 'list', parts: inlineSegments(line) };
|
|
||||||
if (/^\s*>\s?/.test(line)) return { block: 'quote', parts: inlineSegments(line) };
|
|
||||||
return { block: 'p', parts: inlineSegments(line) };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Between what you see and what is stored.
|
||||||
|
*
|
||||||
|
* The editor is rich text; the note on disk is Markdown. That is not a compromise
|
||||||
|
* anybody has to live with silently — it is the reason the export is trustworthy:
|
||||||
|
* the file a note becomes is the file it already was. So these two functions are
|
||||||
|
* the seam, and they have to agree with each other exactly, or a note changes
|
||||||
|
* shape a little every time it is opened and saved.
|
||||||
|
*
|
||||||
|
* The set of styles is deliberately small and matches what Apple Notes offers,
|
||||||
|
* because that is the vocabulary somebody coming from there already has:
|
||||||
|
*
|
||||||
|
* Titel → #
|
||||||
|
* Overskrift → ##
|
||||||
|
* Underrubrik → ###
|
||||||
|
* Brødtekst → nothing
|
||||||
|
* Monotype → four spaces
|
||||||
|
* Punktliste → -
|
||||||
|
* Nummereret → 1.
|
||||||
|
* Blokcitat → >
|
||||||
|
*
|
||||||
|
* Bold, italic and strikethrough have Markdown of their own. Underline does not —
|
||||||
|
* there is no such thing in any Markdown dialect — so it is stored as inline HTML,
|
||||||
|
* which every Markdown reader passes through untouched. It is the one place where
|
||||||
|
* the file is not pure Markdown, and it is that rather than dropping a style the
|
||||||
|
* source program has.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BLOCKS = [
|
||||||
|
{ style: 'title', tag: 'h1', prefix: '# ' },
|
||||||
|
{ style: 'heading', tag: 'h2', prefix: '## ' },
|
||||||
|
{ style: 'subheading', tag: 'h3', prefix: '### ' },
|
||||||
|
{ style: 'quote', tag: 'blockquote', prefix: '> ' },
|
||||||
|
{ style: 'mono', tag: 'pre', prefix: ' ' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const escapeHtml = (s) =>
|
||||||
|
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
/** Inline marks, innermost first so a bold word inside italics survives both. */
|
||||||
|
function inlineToHtml(text) {
|
||||||
|
return escapeHtml(text)
|
||||||
|
.replace(/<u>(.+?)<\/u>/g, '<u>$1</u>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/~~(.+?)~~/g, '<s>$1</s>')
|
||||||
|
.replace(/(^|[^*])\*([^*]+)\*/g, '$1<em>$2</em>');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* markdownToHtml renders a note for the editor.
|
||||||
|
*
|
||||||
|
* Every line becomes exactly one block. Markdown would normally join consecutive
|
||||||
|
* lines into one paragraph; here it must not, because the person typed a line and
|
||||||
|
* expects to get it back — an editor that silently reflows what somebody wrote is
|
||||||
|
* an editor they stop trusting.
|
||||||
|
*/
|
||||||
|
export function markdownToHtml(markdown) {
|
||||||
|
const lines = (markdown ?? '').split('\n');
|
||||||
|
const out = [];
|
||||||
|
let list = null;
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (list) {
|
||||||
|
out.push(`</${list}>`);
|
||||||
|
list = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const bullet = /^[-*+]\s+(.*)$/.exec(line);
|
||||||
|
const numbered = /^\d+\.\s+(.*)$/.exec(line);
|
||||||
|
|
||||||
|
if (bullet || numbered) {
|
||||||
|
const want = bullet ? 'ul' : 'ol';
|
||||||
|
if (list !== want) {
|
||||||
|
closeList();
|
||||||
|
out.push(`<${want}>`);
|
||||||
|
list = want;
|
||||||
|
}
|
||||||
|
out.push(`<li>${inlineToHtml((bullet ?? numbered)[1])}</li>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
|
||||||
|
const block = BLOCKS.find((b) =>
|
||||||
|
b.prefix === ' ' ? line.startsWith(' ') : line.startsWith(b.prefix)
|
||||||
|
);
|
||||||
|
if (block) {
|
||||||
|
out.push(`<${block.tag}>${inlineToHtml(line.slice(block.prefix.length))}</${block.tag}>`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty line is still a line. Without the break the browser collapses the
|
||||||
|
// block to nothing and the caret has nowhere to stand.
|
||||||
|
out.push(`<p>${line.trim() === '' ? '<br>' : inlineToHtml(line)}</p>`);
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
return out.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inline marks, read back off the DOM. */
|
||||||
|
function inlineToMarkdown(node) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) return node.textContent;
|
||||||
|
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||||
|
|
||||||
|
const inner = [...node.childNodes].map(inlineToMarkdown).join('');
|
||||||
|
switch (node.tagName) {
|
||||||
|
case 'STRONG':
|
||||||
|
case 'B':
|
||||||
|
return inner ? `**${inner}**` : '';
|
||||||
|
case 'EM':
|
||||||
|
case 'I':
|
||||||
|
return inner ? `*${inner}*` : '';
|
||||||
|
case 'S':
|
||||||
|
case 'STRIKE':
|
||||||
|
case 'DEL':
|
||||||
|
return inner ? `~~${inner}~~` : '';
|
||||||
|
case 'U':
|
||||||
|
return inner ? `<u>${inner}</u>` : '';
|
||||||
|
case 'CODE':
|
||||||
|
return inner ? `\`${inner}\`` : '';
|
||||||
|
case 'SPAN': {
|
||||||
|
// The browser's own way of saying bold or italic when it is writing styles
|
||||||
|
// rather than tags. Read as well as tags, because `styleWithCSS` is not
|
||||||
|
// honoured everywhere and a note must not lose a format on the way out.
|
||||||
|
const style = node.getAttribute('style') ?? '';
|
||||||
|
if (/font-weight:\s*(bold|[6-9]00)/.test(style)) return inner ? `**${inner}**` : '';
|
||||||
|
if (/font-style:\s*italic/.test(style)) return inner ? `*${inner}*` : '';
|
||||||
|
if (/text-decoration[^;]*line-through/.test(style)) return inner ? `~~${inner}~~` : '';
|
||||||
|
if (/text-decoration[^;]*underline/.test(style)) return inner ? `<u>${inner}</u>` : '';
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
case 'BR':
|
||||||
|
return '';
|
||||||
|
default:
|
||||||
|
return inner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* htmlToMarkdown reads the editor back.
|
||||||
|
*
|
||||||
|
* Written against the DOM rather than against a string of HTML, because that is
|
||||||
|
* what contenteditable actually produces — and what it produces is not tidy. The
|
||||||
|
* browser will nest, split and re-wrap as somebody types, and a regular expression
|
||||||
|
* over that is a guess.
|
||||||
|
*/
|
||||||
|
export function htmlToMarkdown(root) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
const walk = (node) => {
|
||||||
|
for (const child of node.childNodes) {
|
||||||
|
if (child.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (child.textContent.trim()) lines.push(child.textContent);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||||
|
|
||||||
|
switch (child.tagName) {
|
||||||
|
case 'UL':
|
||||||
|
case 'OL': {
|
||||||
|
let n = 1;
|
||||||
|
for (const li of child.children) {
|
||||||
|
const text = inlineToMarkdown(li).trim();
|
||||||
|
lines.push(child.tagName === 'UL' ? `- ${text}` : `${n++}. ${text}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'DIV':
|
||||||
|
// The browser's own container when nothing else applies. Read through
|
||||||
|
// it rather than treating it as a block, or every paste adds a level.
|
||||||
|
if ([...child.children].some((c) => /^(P|H[1-3]|UL|OL|PRE|BLOCKQUOTE|DIV)$/.test(c.tagName))) {
|
||||||
|
walk(child);
|
||||||
|
} else {
|
||||||
|
lines.push(inlineToMarkdown(child));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const block = BLOCKS.find((b) => b.tag === child.tagName.toLowerCase());
|
||||||
|
const text = inlineToMarkdown(child);
|
||||||
|
lines.push(block ? block.prefix + text : text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
walk(root);
|
||||||
|
// Trailing blank lines are what an editor accumulates from people pressing
|
||||||
|
// return at the end; they are not content and they grow every time.
|
||||||
|
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
@@ -562,6 +562,31 @@ class AppState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The order of the fixed views, as keys.
|
||||||
|
*
|
||||||
|
* Unknown keys are dropped and missing ones appended, so the stored order never
|
||||||
|
* has to be migrated when a view is added or taken away — which has already
|
||||||
|
* happened once, when Færdige left the sidebar.
|
||||||
|
*/
|
||||||
|
navOrder(keys) {
|
||||||
|
const saved = this.user?.nav_order ?? [];
|
||||||
|
const known = new Set(keys);
|
||||||
|
const ordered = saved.filter((k) => known.has(k));
|
||||||
|
return [...ordered, ...keys.filter((k) => !ordered.includes(k))];
|
||||||
|
}
|
||||||
|
|
||||||
|
async setNavOrder(order) {
|
||||||
|
const before = this.user?.nav_order ?? [];
|
||||||
|
this.user = { ...this.user, nav_order: order };
|
||||||
|
try {
|
||||||
|
await api.setNavOrder(order);
|
||||||
|
} catch (e) {
|
||||||
|
this.user = { ...this.user, nav_order: before };
|
||||||
|
this.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sectionCollapsed(key) {
|
sectionCollapsed(key) {
|
||||||
return (this.user?.sidebar_collapsed ?? []).includes(key);
|
return (this.user?.sidebar_collapsed ?? []).includes(key);
|
||||||
}
|
}
|
||||||
@@ -844,6 +869,45 @@ export const THEMES = [
|
|||||||
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
|
{ id: 'contrast', name: 'theme.contrast', note: 'theme.contrastNote', dark: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The looks: how it reads, as opposed to how bright it is.
|
||||||
|
*
|
||||||
|
* Separate from the themes because they are separate questions. Somebody who
|
||||||
|
* wants a serif face at night should not have to give up the dark palette to get
|
||||||
|
* one, and the two multiply out to fifteen appearances from eight small blocks of
|
||||||
|
* CSS rather than needing one block each.
|
||||||
|
*/
|
||||||
|
export const LOOKS = [
|
||||||
|
{ id: 'verdande', name: 'look.verdande', note: 'look.verdandeNote' },
|
||||||
|
{ id: 'rolig', name: 'look.rolig', note: 'look.roligNote' },
|
||||||
|
{ id: 'taet', name: 'look.taet', note: 'look.taetNote' },
|
||||||
|
{ id: 'terminal', name: 'look.terminal', note: 'look.terminalNote' }
|
||||||
|
];
|
||||||
|
|
||||||
|
class Look {
|
||||||
|
current = $state(
|
||||||
|
typeof document === 'undefined'
|
||||||
|
? 'verdande'
|
||||||
|
: (document.documentElement.dataset.look ?? 'verdande')
|
||||||
|
);
|
||||||
|
|
||||||
|
set(id) {
|
||||||
|
if (!LOOKS.some((l) => l.id === id)) return;
|
||||||
|
this.current = id;
|
||||||
|
// The default carries no attribute, so the plain tokens are the plain tokens
|
||||||
|
// and there is nothing to override them back to.
|
||||||
|
if (id === 'verdande') delete document.documentElement.dataset.look;
|
||||||
|
else document.documentElement.dataset.look = id;
|
||||||
|
try {
|
||||||
|
localStorage.setItem('verdande:look', id);
|
||||||
|
} catch {
|
||||||
|
// Private browsing; the choice simply will not persist.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const look = new Look();
|
||||||
|
|
||||||
/** Theme, kept in localStorage and applied to the document element. */
|
/** Theme, kept in localStorage and applied to the document element. */
|
||||||
class Theme {
|
class Theme {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
/** Konto: who you are, your password, and the second factor. */
|
/** Konto: who you are, your password, and the second factor. */
|
||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app, theme, THEMES } from '$lib/stores.svelte.js';
|
import { app, theme, THEMES, look, LOOKS } from '$lib/stores.svelte.js';
|
||||||
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
import { t, plural, i18n } from '$lib/i18n.svelte.js';
|
||||||
import { supported, register } from '$lib/passkey.js';
|
import { supported, register } from '$lib/passkey.js';
|
||||||
import { ago } from '$lib/when.js';
|
import { ago } from '$lib/when.js';
|
||||||
@@ -330,6 +330,29 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- The second axis. A theme says how bright, a look says how it reads — and
|
||||||
|
they are separate settings because they are separate questions. -->
|
||||||
|
<div class="looks">
|
||||||
|
<h3>{t('account.look')}</h3>
|
||||||
|
<p class="hint">{t('account.lookHint')}</p>
|
||||||
|
<div class="look-row">
|
||||||
|
{#each LOOKS as option (option.id)}
|
||||||
|
<button
|
||||||
|
class="look-card"
|
||||||
|
class:chosen={look.current === option.id}
|
||||||
|
onclick={() => look.set(option.id)}
|
||||||
|
aria-pressed={look.current === option.id}
|
||||||
|
>
|
||||||
|
<!-- Each card is written in the face it selects, because that is the
|
||||||
|
only part of the choice a name cannot carry. -->
|
||||||
|
<span class="look-sample" data-look={option.id} aria-hidden="true">Aa</span>
|
||||||
|
<span class="theme-name">{t(option.name)}</span>
|
||||||
|
<span class="theme-note">{t(option.note)}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
@@ -528,6 +551,46 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.looks {
|
||||||
|
margin-top: var(--s4);
|
||||||
|
padding-top: var(--s3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.looks h3 {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
margin-bottom: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.look-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: var(--s2);
|
||||||
|
margin-top: var(--s3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.look-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--s1);
|
||||||
|
padding: var(--s3);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.look-card.chosen {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.look-sample {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { api, humanMessage } from '$lib/api.js';
|
import { api, humanMessage } from '$lib/api.js';
|
||||||
import { app } from '$lib/stores.svelte.js';
|
import { app } from '$lib/stores.svelte.js';
|
||||||
import { t } from '$lib/i18n.svelte.js';
|
import { t } from '$lib/i18n.svelte.js';
|
||||||
import { linesOf } from '$lib/markdown.js';
|
import NoteEditor from '$lib/components/NoteEditor.svelte';
|
||||||
|
|
||||||
let notes = $state([]);
|
let notes = $state([]);
|
||||||
let selected = $state(null);
|
let selected = $state(null);
|
||||||
@@ -48,6 +48,29 @@
|
|||||||
draft = note?.body ?? '';
|
draft = note?.body ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first line or so of a note, without its marks.
|
||||||
|
*
|
||||||
|
* The list showed the Markdown as stored — "# Møde med Anders" and "**fed**" —
|
||||||
|
* which is exactly the thing the editor was built to stop showing. What is
|
||||||
|
* wanted here is the sentence, not the notation.
|
||||||
|
*/
|
||||||
|
function plain(body) {
|
||||||
|
return (body ?? '')
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
|
.replace(/^\s*>\s?/gm, '')
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
||||||
|
.replace(/~~([^~]+)~~/g, '$1')
|
||||||
|
.replace(/<\/?u>/g, '')
|
||||||
|
.replace(/`([^`]+)`/g, '$1')
|
||||||
|
.replace(/\*([^*]+)\*/g, '$1')
|
||||||
|
.replace(/\[\[([^\]]+)\]\]/g, '$1')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 90);
|
||||||
|
}
|
||||||
|
|
||||||
async function create() {
|
async function create() {
|
||||||
try {
|
try {
|
||||||
const note = await api.createNote({ body: '' });
|
const note = await api.createNote({ body: '' });
|
||||||
@@ -89,6 +112,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Favourites are the pinned ones. The list already puts them first, so marking
|
||||||
|
* one is the same act as saying "keep this where I can see it" — one idea, not
|
||||||
|
* two that have to be explained apart.
|
||||||
|
*/
|
||||||
|
async function favourite(note) {
|
||||||
|
const pinned = !note.pinned;
|
||||||
|
// Moved before the answer comes back: a star that waits for a round trip
|
||||||
|
// feels broken on a slow connection, and the cost of being wrong is a star.
|
||||||
|
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned } : n));
|
||||||
|
try {
|
||||||
|
await api.updateNote(note.id, { pinned });
|
||||||
|
await load(query);
|
||||||
|
} catch (e) {
|
||||||
|
notes = notes.map((n) => (n.id === note.id ? { ...n, pinned: !pinned } : n));
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves the note into a project, which is what sharing it means: everybody who
|
||||||
|
* can read the project can read the note, and an editor can change it. Choosing
|
||||||
|
* the empty option takes it back out and makes it the author's alone again.
|
||||||
|
*/
|
||||||
|
async function share(projectId) {
|
||||||
|
if (!selected) return;
|
||||||
|
const id = selected.id;
|
||||||
|
try {
|
||||||
|
const saved = await api.updateNote(id, { project_id: projectId });
|
||||||
|
notes = notes.map((n) => (n.id === id ? saved : n));
|
||||||
|
if (selected?.id === id) selected = saved;
|
||||||
|
app.toast(projectId ? t('notes.shared') : t('notes.unshared'));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(note) {
|
async function remove(note) {
|
||||||
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
|
if (!confirm(t('notes.deleteNote', { name: note.title || t('notes.untitled') }))) return;
|
||||||
try {
|
try {
|
||||||
@@ -100,14 +160,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a link is called on screen.
|
||||||
|
*
|
||||||
|
* A project tag is stored folded, because it is a key — but showing the folded
|
||||||
|
* key would mean a note that says #garageristeriet is labelled with a spelling
|
||||||
|
* that appears nowhere, neither in the note nor on the project. The project's
|
||||||
|
* own name is the right answer, and the fold is what makes it findable.
|
||||||
|
*/
|
||||||
|
function label(link) {
|
||||||
|
if (link.kind !== 'project') return link.target_id;
|
||||||
|
const project = app.projects.find(
|
||||||
|
(p) => p.name.toLowerCase() === link.target_id.toLowerCase()
|
||||||
|
);
|
||||||
|
return '#' + (project?.name ?? link.target_id);
|
||||||
|
}
|
||||||
|
|
||||||
// What the note points at, for the panel under the editor.
|
// What the note points at, for the panel under the editor.
|
||||||
let links = $derived(selected?.links ?? []);
|
let links = $derived(selected?.links ?? []);
|
||||||
|
|
||||||
// The styled twin of what is in the textarea. Recomputed on every keystroke,
|
|
||||||
// which sounds expensive and is not: it is a split and a handful of regexes
|
|
||||||
// over a page of text, and it runs in the same frame as the character that
|
|
||||||
// caused it.
|
|
||||||
let lines = $derived(linesOf(draft));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
<svelte:head><title>{t('notes.title')} · verdande</title></svelte:head>
|
||||||
@@ -135,10 +207,22 @@
|
|||||||
<ul>
|
<ul>
|
||||||
{#each notes as note (note.id)}
|
{#each notes as note (note.id)}
|
||||||
<li>
|
<li>
|
||||||
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
|
<div class="rowline">
|
||||||
<strong>{note.title || t('notes.untitled')}</strong>
|
<button class="row" class:on={selected?.id === note.id} onclick={() => open(note)}>
|
||||||
<span class="preview">{note.body.slice(0, 80)}</span>
|
<strong>{note.title || t('notes.untitled')}</strong>
|
||||||
</button>
|
<span class="preview">{plain(note.body)}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="star"
|
||||||
|
class:on={note.pinned}
|
||||||
|
onclick={() => favourite(note)}
|
||||||
|
aria-pressed={note.pinned}
|
||||||
|
aria-label={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||||
|
title={note.pinned ? t('notes.unfavourite') : t('notes.favourite')}
|
||||||
|
>
|
||||||
|
{note.pinned ? '★' : '☆'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -147,44 +231,44 @@
|
|||||||
|
|
||||||
<section class="editor">
|
<section class="editor">
|
||||||
{#if selected}
|
{#if selected}
|
||||||
<!-- The mirror sits behind the textarea and must match it exactly: same font,
|
<NoteEditor
|
||||||
same size, same padding, same wrapping. Any difference shows up as
|
note={selected}
|
||||||
formatting that drifts away from the text as the note grows.
|
onchange={(body) => {
|
||||||
|
draft = body;
|
||||||
A contenteditable would let the text be styled directly and would also
|
typed();
|
||||||
break undo, autocorrect and IME input — a bad trade in the one place in
|
}}
|
||||||
this program where somebody writes for half an hour at a stretch. -->
|
onsave={save}
|
||||||
<div class="paper">
|
/>
|
||||||
<div class="mirror" aria-hidden="true">
|
|
||||||
{#each lines as line}
|
|
||||||
<div class={line.block}>
|
|
||||||
{#each line.parts as part}
|
|
||||||
{#if part.kind}<span class={part.kind}>{part.text}</span>{:else}{part.text}{/if}
|
|
||||||
{/each}{#if !line.parts.length} {/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea
|
|
||||||
bind:value={draft}
|
|
||||||
oninput={typed}
|
|
||||||
onblur={save}
|
|
||||||
spellcheck="true"
|
|
||||||
aria-label={t('notes.body')}
|
|
||||||
placeholder={t('notes.placeholder')}
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
<span class="hint">{saving ? t('notes.saving') : t('notes.saved')}</span>
|
||||||
{#if links.length}
|
{#if links.length}
|
||||||
<span class="links">
|
<span class="links">
|
||||||
{#each links as link}
|
{#each links as link}
|
||||||
<span class="link">{link.kind === 'project' ? '#' : ''}{link.target_id}</span>
|
<span class="link">{label(link)}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="remove" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
<!-- Sharing a note is filing it in a project.
|
||||||
|
Not an access list of its own: projects already have members, roles
|
||||||
|
and invitations, all of them tested and all of them things people
|
||||||
|
have already learnt here. A second way to give somebody access is a
|
||||||
|
second place to get it wrong, and the two would disagree on the day
|
||||||
|
one of them was changed. -->
|
||||||
|
<label class="share">
|
||||||
|
<span class="hint">{t('notes.shareWith')}</span>
|
||||||
|
<select value={selected.project_id ?? ''} onchange={(e) => share(e.currentTarget.value)}>
|
||||||
|
<option value="">{t('notes.private')}</option>
|
||||||
|
{#each app.projects.filter((p) => !p.is_inbox) as project (project.id)}
|
||||||
|
<option value={project.id}>{project.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="button" onclick={save}>{t('notes.save')}</button>
|
||||||
|
<button class="button danger" onclick={() => remove(selected)}>{t('notes.delete')}</button>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="empty">{t('notes.pickOne')}</p>
|
<p class="empty">{t('notes.pickOne')}</p>
|
||||||
@@ -194,8 +278,10 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.notes {
|
.notes {
|
||||||
/* Air above the heading, so it does not sit against the top bar. */
|
/* Air on all sides. The heading, the search box and the list all sat flush
|
||||||
padding-top: var(--s3);
|
against the sidebar's rule, which reads as a rendering fault rather than
|
||||||
|
as a layout. */
|
||||||
|
padding: var(--s3) 0 0 var(--s4);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(220px, 300px) 1fr;
|
grid-template-columns: minmax(220px, 300px) 1fr;
|
||||||
gap: var(--s4);
|
gap: var(--s4);
|
||||||
@@ -245,9 +331,34 @@
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rowline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
flex: none;
|
||||||
|
width: 24px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rowline:hover .star,
|
||||||
|
.star.on,
|
||||||
|
.star:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star.on {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: var(--s2);
|
padding: var(--s2);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -398,15 +509,46 @@
|
|||||||
font-family: var(--mono, ui-monospace, monospace);
|
font-family: var(--mono, ui-monospace, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove {
|
.share {
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
color: var(--ink-faint);
|
align-items: center;
|
||||||
|
gap: var(--s1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.remove:hover {
|
.share select {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
padding: 2px var(--s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Real buttons. They were bare words, which on a Mac reads as a web page rather
|
||||||
|
than as something you can press. */
|
||||||
|
.actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--s1) var(--s3);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.danger {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button.danger:hover {
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
.empty,
|
.empty,
|
||||||
.hint {
|
.hint {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-faint);
|
||||||
|
|||||||
@@ -43,21 +43,33 @@
|
|||||||
load(id);
|
load(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Notes that mention this project. Looked up by name rather than by id, because
|
// Two ways a note belongs here, and both count.
|
||||||
// a #tag is written by hand and a person types the name — the same string the
|
//
|
||||||
// quick-add parser reads out of a task.
|
// Filed in the project — which is what sharing a note means — and mentioning it
|
||||||
|
// by #tag, which is what somebody does while writing without stopping to file
|
||||||
|
// anything. Looked up by name for the second, because a #tag is written by hand
|
||||||
|
// and a person types the name: the same string the quick-add parser reads out of
|
||||||
|
// a task.
|
||||||
let notes = $state([]);
|
let notes = $state([]);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
const id = project?.id;
|
||||||
const name = project?.name;
|
const name = project?.name;
|
||||||
if (!name) {
|
if (!id || !name) {
|
||||||
notes = [];
|
notes = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
api
|
Promise.all([
|
||||||
.notesLinking('project', name)
|
api.notes({ project: id }).catch(() => ({ notes: [] })),
|
||||||
.then((r) => (notes = r.notes ?? []))
|
api.notesLinking('project', name).catch(() => ({ notes: [] }))
|
||||||
.catch(() => (notes = []));
|
]).then(([filed, mentioned]) => {
|
||||||
|
const seen = new Set();
|
||||||
|
notes = [...(filed.notes ?? []), ...(mentioned.notes ?? [])].filter((n) => {
|
||||||
|
if (seen.has(n.id)) return false;
|
||||||
|
seen.add(n.id);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
async function load(projectId) {
|
async function load(projectId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user