File size: 12,236 Bytes
e5e233a
 
9b49194
e5e233a
9d479ea
e5e233a
 
 
 
 
 
 
 
9b49194
e5e233a
 
 
 
 
 
 
93ef16f
 
e5e233a
 
 
9b49194
 
 
 
363fbdc
 
e5e233a
 
 
 
363fbdc
 
 
 
 
 
 
 
 
e5e233a
 
 
 
9b49194
e5e233a
 
 
 
9b49194
e5e233a
 
 
 
 
 
 
9b49194
 
e5e233a
 
 
 
 
 
 
 
9b49194
e5e233a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ad2aff
 
 
 
 
 
 
 
 
 
 
 
e5e233a
 
 
 
 
 
 
 
 
 
363fbdc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e5e233a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363fbdc
 
e5e233a
 
 
 
1f1507e
e5e233a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2ad2aff
 
 
 
 
 
 
 
 
 
 
 
e5e233a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b49194
e5e233a
 
 
 
 
26dfc7b
e5e233a
 
9b49194
e5e233a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26dfc7b
e5e233a
 
 
9d479ea
 
 
 
 
 
 
 
 
e5e233a
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
import * as Y from 'yjs'
import * as store from './store.js'
import { newId, b64encode, docNameFor, projectIdOf, pageSlugOf } from './util.js'
import { markdownToBlocks } from './md.js'
import { hocuspocus, buildYBlock, createSuggestion, createPageProposal, deleteDoc } from './collab.js'

const FIELD = 'default'

// A personal test project seeded with every construct the suggestion/comment
// system supports, so the owner can play with accepts/rejects/merges and reset
// back to this exact state at any time.

async function withDoc(docName, fn) {
  const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } })
  try {
    let result
    await conn.transact(document => {
      result = fn(document)
    })
    return result
  } finally {
    // see collab.js: an immediate unload races with clients connecting
    await conn.disconnect({ unloadImmediately: false })
  }
}

const pageField = docName => (pageSlugOf(docName) === 'home' ? FIELD : `page:${pageSlugOf(docName)}`)
const pageMap = (document, base, docName) =>
  document.getMap(pageSlugOf(docName) === 'home' ? base : `${base}:${pageSlugOf(docName)}`)

function svgMarkup(label, color) {
  return `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="240" viewBox="0 0 480 240">
  <rect width="480" height="240" rx="12" fill="${color}"/>
  <polyline points="40,190 120,150 200,168 280,96 360,120 440,60" fill="none" stroke="white" stroke-width="5" stroke-linecap="round"/>
  <text x="40" y="46" fill="white" font-family="sans-serif" font-size="22" font-weight="bold">${label}</text>
</svg>`
}

function svgUpload(label, color) {
  return '/files/' + store.saveUpload(Buffer.from(svgMarkup(label, color)), 'image/svg+xml')
}

// self-contained data: URI so a sandboxed embed can show it with no network access
function svgDataUri(label, color) {
  return 'data:image/svg+xml;base64,' + Buffer.from(svgMarkup(label, color)).toString('base64')
}

function seedPage(docName, markdown) {
  return withDoc(docName, document => {
    const fragment = document.getXmlFragment(pageField(docName))
    if (fragment.length) fragment.delete(0, fragment.length)
    // the doc may still be loaded in memory from before the reset — the file
    // deletion alone doesn't clear its maps
    for (const mapName of ['suggestions', 'threads']) {
      const map = pageMap(document, mapName, docName)
      for (const key of [...map.keys()]) map.delete(key)
    }
    fragment.insert(0, markdownToBlocks(markdown).map(buildYBlock))
  })
}

// server-side comment thread on a character range of block `blockIdx`
function seedThread(document, docName, { blockIdx, from, to, messages, resolved = false, suggestionId = null }) {
  const fragment = document.getXmlFragment(pageField(docName))
  const block = fragment.get(blockIdx)
  const text = block?.get(0)
  if (!(text instanceof Y.XmlText)) return null
  const len = text.length
  const a = Math.min(from, len)
  const b = Math.min(to, len)
  const threadId = newId(10)
  const ythread = new Y.Map()
  pageMap(document, 'threads', docName).set(threadId, ythread)
  ythread.set('anchorStart', b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, a))))
  ythread.set('anchorEnd', b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, b))))
  ythread.set('excerpt', text.toString().slice(a, b))
  ythread.set('resolved', resolved)
  if (suggestionId) ythread.set('suggestionId', suggestionId)
  const arr = new Y.Array()
  ythread.set('messages', arr)
  arr.push(messages.map(m => ({ id: newId(10), ts: Date.now(), ...m })))
  return threadId
}

const HOME_MD = `# Playground

This project is a safe sandbox: every kind of suggestion and comment lives here. Accept, reject, undo, merge — then hit **Reset** in the header to restore this exact state.

## Plain text

The quick brown fox jumps over the lazy dog, and the document keeps calm. This paragraph has a small wording suggestion attached to it.

This second paragraph carries two competing suggestions at once — both stay visible and either one can be accepted.

Formatting survives suggestions: **bold**, *italic*, [links](https://huggingface.co) and \`inline code\` all round-trip.

## Lists

- alpha
- beta
- gamma

1. first step
2. second step

## Checklist

- [x] task lists render with checkboxes
- [ ] this one has a suggestion attached

## Table

| Feature | Status |
| --- | --- |
| Suggestions | done |
| Tables | needs a suggestion |

## Code

\`\`\`python
def greet(name):
    message = "Hello, " + name
    return message
\`\`\`

> A blockquote to iterate on: this quote has a three-generation revision chain — use the arrows on its card.

## Live HTML embed

Arbitrary HTML renders in a sandboxed frame — ideal for aligning figures side by side or dropping in an animated explainer. Hover it and hit **Edit HTML** to tweak the markup. It runs with no access to the page, your account, or the network.

\`\`\`html-embed
<style>
  body { font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; }
  .row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
  figure { margin: 0; }
  figure img { width: 100%; border-radius: 10px; display: block; }
  figcaption { font-size: 13px; color: #666; text-align: center; margin-top: 6px; }
  .bar { height: 10px; border-radius: 5px; margin-top: 18px;
    background: linear-gradient(90deg, #1d5c45, #7c4a9c, #1d5c45);
    background-size: 200% 100%; animation: slide 3s linear infinite; }
  @keyframes slide { to { background-position: -200% 0; } }
</style>
<div class="row">
  <figure><img src="__EMBED_IMG1__"><figcaption>Figure A — aligned</figcaption></figure>
  <figure><img src="__EMBED_IMG2__"><figcaption>Figure B — aligned</figcaption></figure>
</div>
<div class="bar"></div>
\`\`\`

## Images

Select the image below to try resizing and alignment.

The paragraph after the images carries an image suggestion.

That is all — happy testing!`

const NOTES_MD = `# Notes

A second page, to try the sidebar, page navigation, and cross-page agent work.

- the structure page defines the tree
- suggest changes to it like any text`

export async function seedPlayground(username, existingId = null) {
  const id = existingId || newId(8)
  if (existingId) await deleteDoc(existingId)
  store.upsertRegistry(id, {
    title: 'Playground',
    createdBy: username,
    createdAt: Date.now(),
    updatedAt: Date.now(),
    playground: true,
    pages: {},
  })

  const img1 = svgUpload('sample figure', '#1d5c45')
  const img2 = svgUpload('suggested chart', '#7c4a9c')
  const home = HOME_MD.replace('Select the image below', `![sample figure](${img1})\n\nSelect the image above`)
    .replace('__EMBED_IMG1__', svgDataUri('figure A', '#1d5c45'))
    .replace('__EMBED_IMG2__', svgDataUri('figure B', '#7c4a9c'))

  await seedPage(id, home)
  await seedPage(docNameFor(id, 'notes'), NOTES_MD)
  store.upsertPageMeta(id, 'notes', { title: 'Notes', createdAt: Date.now() })
  await seedPage(docNameFor(id, '_structure'), '```yaml\n- home\n- notes\n```')
  store.upsertPageMeta(id, '_structure', { title: 'Structure', createdAt: Date.now() })

  // block indices in HOME_MD (after image insertion): find dynamically
  const blocks = markdownToBlocks(home)
  const idxOf = needle => blocks.findIndex(b => JSON.stringify(b).includes(needle))
  const agent = { author: 'demo-agent', authorType: 'agent' }
  const mk = (blockIdx, replacement, rationale, extra = {}) =>
    createSuggestion(id, { blockIndex: blockIdx, replacementMarkdown: replacement, rationale, ...agent, ...extra })

  // 1. small inline rewrite
  await mk(
    idxOf('quick brown fox'),
    'The quick brown fox leaps over the lazy dog, and the document stays calm. This paragraph has a small wording suggestion attached to it.',
    'Tighter verbs: jumps → leaps, keeps → stays.'
  )
  // 2. two competing suggestions on one paragraph
  const competing = idxOf('two competing suggestions')
  await mk(competing, 'This second paragraph carries two rival suggestions at once — both stay visible and either one can be accepted.', 'Option A: "rival".')
  await mk(competing, 'This second paragraph demonstrates overlapping suggestions — both stay visible and either one can be accepted.', 'Option B: "demonstrates overlapping".')
  // 3. list: add an item
  await mk(idxOf('gamma'), '- alpha\n- beta\n- gamma\n- delta', 'Adds the missing fourth item.')
  // task-list suggestion: check off the open item
  await mk(
    idxOf('this one has a suggestion attached'),
    '- [x] task lists render with checkboxes\n- [x] this one has a suggestion attached',
    'Mark the second task as done.'
  )
  // table suggestion: fill in the pending status
  await mk(
    idxOf('needs a suggestion'),
    '| Feature | Status |\n| --- | --- |\n| Suggestions | done |\n| Tables | done |',
    'Update the table: tables are done.'
  )
  // 4. new section insertion (formatted panel)
  await mk(
    idxOf('second step'),
    '1. first step\n2. second step\n\n## Checklist\n\n- [links](https://huggingface.co) render in panels\n- **bold** does too',
    'Inserts a new section after the numbered list.'
  )
  // 5. codeblock rewrite (word diff inside code)
  await mk(
    idxOf('greet'),
    '```python\ndef greet(name):\n    message = f"Hello, {name}!"\n    return message\n```',
    'Use an f-string and add the exclamation mark.'
  )
  // 6. three-generation revision chain on the blockquote
  const quoteIdx = idxOf('three-generation')
  const s1 = await mk(quoteIdx, '> A blockquote worth iterating on: generation one of the revision chain.', 'First attempt.')
  const s2 = await createSuggestion(id, {
    supersedes: s1.suggestion.id,
    replacementMarkdown: '> A blockquote worth iterating on: generation two, a bit sharper.',
    rationale: 'Second attempt — sharper.',
    ...agent,
  })
  await createSuggestion(id, {
    supersedes: s2.suggestion.id,
    replacementMarkdown: '> A blockquote worth iterating on: generation three, the keeper. Use ‹ › on the card to compare.',
    rationale: 'Third attempt — the keeper.',
    ...agent,
  })
  // 7. image suggestion
  await mk(
    idxOf('carries an image suggestion'),
    `The paragraph after the images carries an image suggestion.\n\n![suggested chart](${img2})`,
    'Adds the missing chart below this paragraph.'
  )

  // 8. threads: one open with an attached suggestion, one resolved
  const attachRes = await withDoc(id, document => {
    const openThread = seedThread(document, id, {
      blockIdx: idxOf('happy testing'),
      from: 0,
      to: 16,
      messages: [
        { author: username, authorType: 'user', text: 'Can we end on something stronger? @demo-agent' },
        { author: 'demo-agent', authorType: 'agent', text: 'Proposed a punchier closing line — see the related suggestion.' },
      ],
    })
    seedThread(document, id, {
      blockIdx: idxOf('Formatting survives'),
      from: 0,
      to: 20,
      resolved: true,
      messages: [
        { author: username, authorType: 'user', text: 'Do links work in here?' },
        { author: 'demo-agent', authorType: 'agent', text: 'They do — resolved.' },
      ],
    })
    return { openThread }
  })
  await createSuggestion(id, {
    blockIndex: idxOf('happy testing'),
    replacementMarkdown: 'That is all — go break things, the Reset button has your back!',
    rationale: 'Punchier closing, as requested in the thread.',
    originThreadId: attachRes.openThread,
    ...agent,
  })

  // 9. a proposed NEW page (shows as pending in the sidebar until accepted)
  await createPageProposal(id, {
    title: 'Proposed Page',
    contentMarkdown:
      '# Proposed Page\n\nAn agent proposed this whole page in one suggestion. Accept it from the sidebar to create it, or reject it.\n\n- [ ] try accepting me\n\n| Try | Result |\n| --- | --- |\n| Accept | page is created |',
    rationale: 'Demonstrates one-step new-page proposals.',
    ...agent,
  })

  return { id }
}

export function findPlayground(username) {
  for (const [id, meta] of Object.entries(store.getRegistry())) {
    if (meta.playground && meta.createdBy === username) return id
  }
  return null
}